Repository navigation
Commit b732e91
authored
feat(core): side-effect-free serialization of workflow VM values (#3257)
* feat(core): side-effect-free serialization of workflow VM values
Serialization runs on the host but inspects values constructed inside the
node:vm sandbox, so ordinary dynamic operations dispatch into the sandbox
realm and execute workflow code: `value.toISOString()`, `Array.from(map)`,
`Object.prototype.toString` (via Symbol.toStringTag), `.source`/`.flags`,
`.href`, view `.buffer`/`.byteOffset`/`.byteLength`, and error
`.message`/`.stack`/`.cause` reads.
That is a determinism hazard. A payload is serialized exactly once and is
never re-serialized on replay, so any workflow-visible side effect it
triggers exists only on the live path — a patched `Date.prototype.toISOString`
that consumes a seeded `Math.random()` draw, for example, shifts every
subsequent draw and diverges from replay.
This makes serialization side-effect free where the data allows it, and
observable where it does not:
- Classification uses engine brand checks (node:util types, internal-slot
probes) instead of `instanceof global.X` and Object.prototype.toString, so
it is immune to Symbol.hasInstance, reassigned sandbox globals, and
Symbol.toStringTag spoofs. An unbranded value claiming a brand-decided tag
is now classified as a plain object instead of being routed into an
extractor that requires the real internal slot (unhardened devalue crashes
on that input).
- Extraction goes through intrinsics captured at module load — host boot,
before any workflow bundle runs — invoked with explicit receivers.
Internal slots are realm-agnostic, so host intrinsics read VM-realm
objects without touching the sandbox's patchable prototypes.
- Property access reads through descriptors, so plain data never invokes
anything.
Where workflow code must run because the data lives behind it — getters,
proxies, custom [WORKFLOW_SERIALIZE] methods, toString() on
toStringTag-branded objects like Temporal polyfills — the execution is
preserved for compatibility and recorded in a new `CodecOptions.guestCodeStats`
sink, surfaced as workflow.serialization.guest_code_{executions,details} span
attributes. Consumers that retain a VM across steps can treat a non-empty
report as "serialization may have perturbed VM state".
Engine-provided accessors are deliberately not reported: V8 defines `stack`
as an own accessor on every Error instance, so reporting it would flag every
serialized error. Nativeness is decided with the captured host
Function.prototype.toString; the bound-function caveat is documented in
hardened.ts.
Requires devalue 5.9.0 for the pluggable `operations` option.
* chore: shorten changeset
* fix(core): close review gaps in hardened serialization
Five correctness fixes, all with repros:
- Callable proxies were treated as engine accessors. V8 returns
`function () { [native code] }` from Function.prototype.toString for a
proxy around a function rather than throwing, so a proxy-wrapped getter
was cached as engine-provided and invoked unreported. Gate on
types.isProxy first.
- Host builtins implemented in JavaScript were reported as workflow code.
Node's DOMException.prototype.message/name are ordinary functions, so
the nativeness test failed and every serialized DOMException reported
two getter executions. They belong to the *host* realm, though, and
workflow code cannot author a host-realm function — so provenance is
now decided by nativeness OR host-realm `Function.prototype`, which are
disjoint and together cover both cases (V8 installs `stack` per realm,
so a VM error's getter is native but VM-realm).
- The extraReducers at the two VM call sites were still unhardened, and
they run on every value the earlier reducers do not claim — which is
exactly where the report has to be complete. `instanceof
global.ReadableStream/WritableStream/Request/Response` consulted
Symbol.hasInstance on the sandbox class (14 invocations for an ordinary
payload once the classes are patched), and AbortController's guard did
a bare `value.signal` read, so a non-enumerable `signal` getter ran
with an empty report. All five now walk the prototype chain and read
through descriptors.
- `__closureVarsFn` was invoked unreported on a purity argument that
nothing checked: the property is reachable from workflow code, which
can replace the compiler-generated function. step.ts now registers the
generated function as trusted when it builds the proxy, so provenance
is verified rather than assumed, and an unrecognized function is
reported.
- The URL/URLSearchParams test patched prototypes of *host* classes
injected into the sandbox, mutating them for the rest of the worker
process. Restored in a finally.
Also, per review:
- `dehydrateStepArguments` / `dehydrateWorkflowReturnValue` take an
optional GuestCodeStats out-param, so a retained-VM gate can consume
the report instead of it being spent on span attributes. The
report-completeness tests use it to exercise the real dehydrate path.
- Every intrinsic capture is now optional. The table is built at module
scope, so a missing member was an import-time crash of @workflow/core
rather than a degraded path; only SharedArrayBuffer was guarded, while
URLSearchParams.prototype.size (Node 19.8+) and the WHATWG classes were
assumed. Absent captures now make the corresponding reducer decline to
match.
- Documented that recording is not prevention (a recorded getter calling
Math.random() still advances the run's seeded PRNG), and that a
`{ kind: 'proxy' }` report implies a silent shape change (a proxied Map
serializes as a plain object).
- Parity coverage extended to DataView, boxed primitives, null-prototype
objects, setter-only properties, DOMException, AggregateError, an
accessor-valued Symbol.toStringTag, both RetryableError retryAfter
paths, and a WORKFLOW_SERIALIZE class instance.
* fix(core): keep identifying proxied host classes
Every Next.js e2e job failed on the two webhook tests: the hook POST
returned 404 because `resumeWebhook` could not serialize its step return
value ("Cannot stringify arbitrary non-POJOs"), so no hook was ever
registered.
The value was a `NextRequest`, which Next.js hands over as a **Proxy**.
`isInstanceOfPrototype` rejected proxies outright, so the Request reducer
answered "not a Request" and devalue fell through to the POJO check. The
reasoning behind rejecting them — that proxied built-ins were never
serializable, because internal-slot reads throw on a proxy receiver — is
true for `Map`/`Date`/`URL`, whose reducers read internal slots, but not
for `Request`/`Response`/streams, whose reducers read ordinary
properties. Next's proxy forwards those with the target as receiver, so
they serialized fine before this PR.
Identification now walks through proxies, matching `instanceof`, and
records the traps rather than suppressing the answer. The three reducers
that do read internal slots (URL, URLSearchParams, Headers) fall back to
the dynamic read when the value is a proxy, so their behavior is exactly
what it was before — including throwing for a bare proxy over a built-in,
which threw before too.
Verified against the real thing: the full nextjs-turbopack e2e suite
(135 tests) passes locally, having reproduced the failure first and
confirmed a reverted `serialization.ts` fixed it.
The regression test uses a receiver-correcting proxy, which is what makes
NextRequest work in practice; a comment records that a bare
`new Proxy(request, {})` throws on undici's private slots with or without
this change.
* fix(core): state what the closure-fn mark proves, and correct stale docs
- `isInstanceOfPrototype`'s JSDoc still described the behavior removed in
8bc462f (proxies rejected without firing traps), which is the opposite
of what it now does.
- The `__closureVarsFn` provenance check proves the function was passed to
`useStep`, not that this package generated it: `useStep` is published on
the sandbox global, so workflow code can call it with a function of its
own and have it marked. Renamed `registerTrustedFunction` /
`isTrustedFunction` to `markUseStepClosureFn` / `isUseStepClosureFn` so
the name states the boundary, and documented the laundering caveat
alongside the existing ones. Marking still earns its keep — reporting
every step that captures a variable would bury the signal — and closing
the gap properly needs a compiler-emitted marker, which is a compiler
change.
- Added the missing coverage for both sides of that check: an unmarked
`__closureVarsFn` is invoked and reported, a marked one is invoked and
not.
- `guestCodeStats` was documented as something a retained-VM gate consumes,
but no runtime caller passes a sink; the executions reach telemetry from
every dehydrate path regardless. Reworded both docs to say that, so the
out-param is not mistaken for wiring that already exists.1 parent ee944d2 commit b732e91
15 files changed
Lines changed: 2051 additions & 154 deletions
File tree
- .changeset
- packages/core
- src
- serialization
- reducers
- telemetry
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
105 | | - | |
| 105 | + | |
106 | 106 | | |
107 | 107 | | |
108 | 108 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
62 | 62 | | |
63 | 63 | | |
64 | 64 | | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
65 | 70 | | |
66 | 71 | | |
67 | 72 | | |
| |||
233 | 238 | | |
234 | 239 | | |
235 | 240 | | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
236 | 269 | | |
237 | 270 | | |
238 | 271 | | |
| |||
1568 | 1601 | | |
1569 | 1602 | | |
1570 | 1603 | | |
1571 | | - | |
| 1604 | + | |
| 1605 | + | |
| 1606 | + | |
| 1607 | + | |
| 1608 | + | |
| 1609 | + | |
| 1610 | + | |
1572 | 1611 | | |
1573 | | - | |
1574 | | - | |
1575 | | - | |
1576 | | - | |
1577 | | - | |
| 1612 | + | |
| 1613 | + | |
| 1614 | + | |
| 1615 | + | |
| 1616 | + | |
1578 | 1617 | | |
1579 | | - | |
| 1618 | + | |
| 1619 | + | |
| 1620 | + | |
1580 | 1621 | | |
1581 | 1622 | | |
1582 | 1623 | | |
| |||
1589 | 1630 | | |
1590 | 1631 | | |
1591 | 1632 | | |
| 1633 | + | |
1592 | 1634 | | |
1593 | | - | |
1594 | | - | |
1595 | | - | |
| 1635 | + | |
| 1636 | + | |
| 1637 | + | |
1596 | 1638 | | |
1597 | | - | |
| 1639 | + | |
1598 | 1640 | | |
1599 | 1641 | | |
1600 | 1642 | | |
1601 | 1643 | | |
1602 | | - | |
| 1644 | + | |
| 1645 | + | |
| 1646 | + | |
| 1647 | + | |
| 1648 | + | |
1603 | 1649 | | |
1604 | | - | |
1605 | | - | |
1606 | | - | |
1607 | | - | |
1608 | | - | |
1609 | | - | |
1610 | | - | |
| 1650 | + | |
| 1651 | + | |
| 1652 | + | |
| 1653 | + | |
| 1654 | + | |
| 1655 | + | |
| 1656 | + | |
1611 | 1657 | | |
1612 | 1658 | | |
1613 | 1659 | | |
| |||
1937 | 1983 | | |
1938 | 1984 | | |
1939 | 1985 | | |
| 1986 | + | |
| 1987 | + | |
| 1988 | + | |
| 1989 | + | |
| 1990 | + | |
| 1991 | + | |
| 1992 | + | |
| 1993 | + | |
| 1994 | + | |
| 1995 | + | |
| 1996 | + | |
| 1997 | + | |
| 1998 | + | |
| 1999 | + | |
| 2000 | + | |
| 2001 | + | |
| 2002 | + | |
| 2003 | + | |
| 2004 | + | |
| 2005 | + | |
| 2006 | + | |
| 2007 | + | |
| 2008 | + | |
| 2009 | + | |
| 2010 | + | |
| 2011 | + | |
| 2012 | + | |
| 2013 | + | |
| 2014 | + | |
| 2015 | + | |
| 2016 | + | |
| 2017 | + | |
| 2018 | + | |
| 2019 | + | |
| 2020 | + | |
| 2021 | + | |
| 2022 | + | |
| 2023 | + | |
| 2024 | + | |
| 2025 | + | |
| 2026 | + | |
| 2027 | + | |
| 2028 | + | |
| 2029 | + | |
| 2030 | + | |
1940 | 2031 | | |
1941 | 2032 | | |
1942 | 2033 | | |
| |||
1946 | 2037 | | |
1947 | 2038 | | |
1948 | 2039 | | |
1949 | | - | |
| 2040 | + | |
| 2041 | + | |
| 2042 | + | |
| 2043 | + | |
| 2044 | + | |
| 2045 | + | |
| 2046 | + | |
| 2047 | + | |
| 2048 | + | |
| 2049 | + | |
1950 | 2050 | | |
1951 | 2051 | | |
1952 | | - | |
| 2052 | + | |
1953 | 2053 | | |
1954 | 2054 | | |
1955 | 2055 | | |
1956 | 2056 | | |
1957 | 2057 | | |
1958 | 2058 | | |
1959 | | - | |
| 2059 | + | |
1960 | 2060 | | |
1961 | 2061 | | |
1962 | 2062 | | |
1963 | | - | |
1964 | | - | |
| 2063 | + | |
| 2064 | + | |
| 2065 | + | |
| 2066 | + | |
| 2067 | + | |
| 2068 | + | |
| 2069 | + | |
1965 | 2070 | | |
1966 | | - | |
1967 | | - | |
| 2071 | + | |
| 2072 | + | |
| 2073 | + | |
1968 | 2074 | | |
1969 | 2075 | | |
1970 | 2076 | | |
1971 | 2077 | | |
1972 | | - | |
1973 | | - | |
| 2078 | + | |
| 2079 | + | |
| 2080 | + | |
| 2081 | + | |
1974 | 2082 | | |
1975 | 2083 | | |
1976 | 2084 | | |
1977 | 2085 | | |
1978 | 2086 | | |
1979 | 2087 | | |
1980 | 2088 | | |
1981 | | - | |
| 2089 | + | |
1982 | 2090 | | |
1983 | | - | |
| 2091 | + | |
| 2092 | + | |
| 2093 | + | |
| 2094 | + | |
1984 | 2095 | | |
1985 | 2096 | | |
1986 | 2097 | | |
1987 | | - | |
| 2098 | + | |
| 2099 | + | |
| 2100 | + | |
| 2101 | + | |
1988 | 2102 | | |
1989 | 2103 | | |
1990 | 2104 | | |
| |||
1996 | 2110 | | |
1997 | 2111 | | |
1998 | 2112 | | |
1999 | | - | |
| 2113 | + | |
| 2114 | + | |
| 2115 | + | |
| 2116 | + | |
2000 | 2117 | | |
2001 | | - | |
2002 | | - | |
2003 | | - | |
2004 | | - | |
2005 | | - | |
2006 | | - | |
2007 | | - | |
2008 | | - | |
| 2118 | + | |
| 2119 | + | |
| 2120 | + | |
| 2121 | + | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + | |
| 2126 | + | |
| 2127 | + | |
| 2128 | + | |
| 2129 | + | |
| 2130 | + | |
| 2131 | + | |
| 2132 | + | |
| 2133 | + | |
| 2134 | + | |
| 2135 | + | |
| 2136 | + | |
| 2137 | + | |
2009 | 2138 | | |
2010 | 2139 | | |
2011 | | - | |
2012 | | - | |
2013 | | - | |
2014 | | - | |
2015 | | - | |
2016 | | - | |
| 2140 | + | |
| 2141 | + | |
| 2142 | + | |
| 2143 | + | |
| 2144 | + | |
| 2145 | + | |
| 2146 | + | |
2017 | 2147 | | |
2018 | | - | |
| 2148 | + | |
2019 | 2149 | | |
2020 | 2150 | | |
2021 | 2151 | | |
| |||
3420 | 3550 | | |
3421 | 3551 | | |
3422 | 3552 | | |
3423 | | - | |
| 3553 | + | |
| 3554 | + | |
| 3555 | + | |
| 3556 | + | |
| 3557 | + | |
| 3558 | + | |
| 3559 | + | |
| 3560 | + | |
| 3561 | + | |
3424 | 3562 | | |
3425 | 3563 | | |
3426 | 3564 | | |
3427 | 3565 | | |
3428 | 3566 | | |
3429 | 3567 | | |
3430 | 3568 | | |
| 3569 | + | |
| 3570 | + | |
| 3571 | + | |
3431 | 3572 | | |
3432 | 3573 | | |
3433 | 3574 | | |
3434 | 3575 | | |
3435 | 3576 | | |
| 3577 | + | |
3436 | 3578 | | |
3437 | 3579 | | |
| 3580 | + | |
3438 | 3581 | | |
3439 | 3582 | | |
3440 | 3583 | | |
| |||
3483 | 3626 | | |
3484 | 3627 | | |
3485 | 3628 | | |
3486 | | - | |
| 3629 | + | |
| 3630 | + | |
| 3631 | + | |
3487 | 3632 | | |
3488 | 3633 | | |
3489 | 3634 | | |
3490 | 3635 | | |
3491 | 3636 | | |
3492 | 3637 | | |
3493 | 3638 | | |
| 3639 | + | |
| 3640 | + | |
| 3641 | + | |
3494 | 3642 | | |
3495 | 3643 | | |
3496 | 3644 | | |
3497 | 3645 | | |
3498 | 3646 | | |
| 3647 | + | |
3499 | 3648 | | |
3500 | 3649 | | |
| 3650 | + | |
3501 | 3651 | | |
3502 | 3652 | | |
3503 | 3653 | | |
| |||
0 commit comments