Skip to content

feat(core): side-effect-free serialization of workflow VM values - #3257

Merged
TooTallNate merged 8 commits into
mainfrom
nrajlich/hardened-vm-serialization
Aug 1, 2026
Merged

feat(core): side-effect-free serialization of workflow VM values#3257
TooTallNate merged 8 commits into
mainfrom
nrajlich/hardened-vm-serialization

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Summary

Serialization runs on the host, but the values it inspects were constructed inside the node:vm sandbox by workflow code. Every ordinary dynamic operation therefore dispatches into the sandbox realm and executes workflow code:

Reducer Executes in the sandbox
Date value.getDate(), value.toISOString()
Map / Set Array.from(value)Symbol.iterator
Headers Array.from(value)Symbol.iterator
RegExp .source / .flags prototype getters
URL / URLSearchParams .href, .size, String(value)
typed arrays / DataView .buffer / .byteOffset / .byteLength getters
every Error reducer .name / .message / .stack / .cause reads
Instance / Class .constructor, .classId reads
classification (all of them) instanceof global.XSymbol.hasInstance; devalue's Object.prototype.toStringSymbol.toStringTag

Why that's a correctness problem, not just hygiene. A payload is serialized exactly once, when its step_started event is prepared, and is never re-serialized on replay. So any workflow-visible side effect serialization triggers exists only on the live execution path. A Date.prototype.toISOString patched to consume one seeded Math.random() draw shifts every subsequent draw — step correlation IDs included — and the next cold start replays a different sequence than the log records. Today that survives only because the VM is destroyed immediately after serializing; it becomes load-bearing the moment a VM is retained across steps.

This PR is standalone and independent of retention — it removes the hazard at the source, and the retention work inherits it.

Approach

  • Classification by engine brand. node:util types + internal-slot probes replace instanceof global.X and Object.prototype.toString. Immune to Symbol.hasInstance, reassigned sandbox globals, and Symbol.toStringTag. A value with no engine brand that claims a brand-decided tag is now classified as a plain object rather than routed into an extractor that requires the real slot — unhardened devalue crashes on that input, so this is strictly better.
  • Extraction through captured intrinsics. Captured at module load (host boot, before any workflow bundle runs) and invoked with explicit receivers. Internal slots are realm-agnostic, so host intrinsics read VM-realm objects without touching the sandbox's patchable prototypes. Map/Set iteration goes through host Map.prototype.entries / Set.prototype.values, so the iterator object — and its next — are host-realm too.
  • Descriptor-based property access. Plain data properties never invoke anything.
  • Unavoidable executions are reported, not blocked. Getters, proxies, custom [WORKFLOW_SERIALIZE] methods, and toString() on toStringTag-branded objects (Temporal polyfills) still run — full compatibility — and land in a new CodecOptions.guestCodeStats sink, surfaced as workflow.serialization.guest_code_executions / …guest_code_details span attributes. A retention gate can treat a non-empty report as "serialization may have perturbed VM state".

Wired in at the two call sites that serialize sandbox values: dehydrateStepArguments and dehydrateWorkflowReturnValue (both pass suspension.globalThis). Host-realm paths — step returns, step errors, client args — are unaffected apart from also being hardened.

Two findings worth calling out

  1. V8 defines stack as an own accessor on every Error instance, with a shared native getter per realm. Reading it always invokes an engine getter, so naively reporting accessor invocations flags every serialized error. Engine-provided accessors are therefore excluded, decided with the captured host Function.prototype.toString. Documented caveats in hardened.ts: a bound function also reports as native code (missed report, never wrong output), and V8's native stack getter can itself call a workflow-defined Error.prepareStackTrace.
  2. getCommonReducers(global) no longer uses its global argument for instanceof. The parameter is kept for API compatibility (revivers still need it) and now documented as such — brand checks are realm-agnostic, which incidentally fixes the cross-realm misclassification the existing RetryableError.retryAfter comment works around.

Tests

42 new tests in serialization/hardened.test.ts, all fixtures minted inside a real createContext() VM and serialized with the VM's globalThis, mirroring dehydrateStepArguments:

  • Patched prototypes never execute — spies on Date.prototype.toISOString/getDate, Map/Set Symbol.iterator (plus entries/values rigged to throw), RegExp.prototype.source/flags, URL.prototype.href, URLSearchParams.prototype.toString, Error.prototype.message, and Object.prototype.toString: zero invocations, correct output.
  • Spoof resistanceSymbol.toStringTag: 'Date', hostile Symbol.hasInstance, reassigned globalThis.Date/Map, and shadowed byteLength/byteOffset on a typed array (the shadowed view still serializes the correct byte range).
  • Reporting — getter recorded once with its key; a proxy recorded once rather than once per trap; [WORKFLOW_SERIALIZE] recorded with its classId; a broad mixed value reports nothing.
  • Wire-format parity — 24 value shapes serialized in-VM byte-compared against the same value on the host, plus Headers round-trip, shared references and cycles.

packages/core unit suite: 1695 passing, 3 pre-existing expected failures. Build clean. (The 9 failing e2e files in this worktree are missing workbench builds, unrelated.)

Dependency

devalue 5.8.15.9.0 for the pluggable operations option (sveltejs/devalue#172, #173). The workspace overrides pin moves with it.

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.
Copilot AI review requested due to automatic review settings July 31, 2026 16:36
@TooTallNate
TooTallNate requested review from a team and ijjk as code owners July 31, 2026 16:36
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7b5a085

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@workflow/core Patch
workflow Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 1, 2026 9:29am
example-nextjs-workflow-webpack Ready Ready Preview Aug 1, 2026 9:29am
example-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-astro-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-express-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-fastify-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-hono-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-nestjs-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-nitro-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-nuxt-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-sveltekit-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-tanstack-start-workflow Ready Ready Preview Aug 1, 2026 9:29am
workbench-vite-workflow Ready Ready Preview Aug 1, 2026 9:29am
workflow-docs Ready Ready Preview, v0 Aug 1, 2026 9:29am
workflow-swc-playground Ready Ready Preview Aug 1, 2026 9:29am
workflow-tarballs Ready Ready Preview Aug 1, 2026 9:29am
workflow-web Ready Ready Preview Aug 1, 2026 9:29am

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 7b5a085 · Sat, 01 Aug 2026 09:49:50 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1310 (+94%) 🔻 1400 🔴 (+39%) 🔻 1457 🔴 (+41%) 🔻 1485 🔴 (-5.9%) 30
TTFS stream 1296 (+43%) 🔻 1389 🔴 (+48%) 🔻 1396 🔴 (+47%) 🔻 1474 🔴 (+52%) 🔻 30
TTFS hook + stream 1521 (+222%) 🔻 1712 🔴 (+31%) 🔻 1759 🔴 (+31%) 🔻 1881 🔴 (+37%) 🔻 30
STSO 1020 steps (inline) 161 (+25%) 🔻 478 (+5.3%) 542 (+6.7%) 804 (+12%) 1016
STSO 1020 steps (queue-hop) 1622 (+8.8%) 3324 (+5.3%) 3324 (+5.3%) 3324 (+5.3%) 3
WO 1020 steps 412497 (+5.8%) 412497 (+5.8%) 412497 (+5.8%) 412497 (+5.8%) 1
SL stream latency 109 (+45%) 🔻 187 🔴 (+63%) 🔻 242 🔴 (+91%) 🔻 357 🔴 (+5.0%) 30
SO stream overhead (text) 110 (+10%) 160 (+18%) 🔻 177 (+4.1%) 199 (-9.1%) 30
SO stream overhead (structured) 112 (+9.8%) 169 (-11%) 202 (-14%) 454 (-30%) 💚 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 382767ms → this run 402865ms (Δ +20098ms, +5%)

  100-150 ms  ┃                         main   3  this   0    -3
  150-200 ms  ██┃██                     main  34  this  19   -15
  200-250 ms  ██████████████┃██         main 122  this 107   -15
  250-300 ms  ████████████████████┃     main 153  this 148    -5
  300-350 ms  █████████████████┃█       main 134  this 129    -5
  350-400 ms  █████████████████░░┃      main 124  this 146   +22
  400-450 ms  ███████████████████┃████  main 172  this 141   -31
  450-500 ms  █████████████████┃███     main 153  this 126   -27
  500-550 ms  ████████░░░░░░┃           main  57  this 109   +52
  550-600 ms  █████┃                    main  37  this  44    +7
  600-650 ms  █┃                        main   7  this  16    +9
  650-700 ms  █┃                        main   7  this  12    +5
  700-750 ms  ┃                         main   5  this   6    +1
  750-800 ms  ┃                         main   1  this   2    +1
  800-850 ms  ┃                         main   2  this   6    +4
  850-900 ms  ┃                         main   0  this   2    +2
  900-950 ms  ┃                         main   1  this   0    -1
 950-1000 ms  ┃                         main   1  this   0    -1
1000-1050 ms  ┃                         main   1  this   1    +0
1050-1100 ms  ┃                         main   0  this   1    +1
1100-1150 ms  ┃                         main   1  this   0    -1
1250-1300 ms  ┃                         main   1  this   0    -1
4500-4550 ms  ┃                         main   0  this   1    +1

1020 steps (queue-hop)

Cumulative STSO time: main 6857ms → this run 8217ms (Δ +1360ms, +20%)

1000-1500 ms  ┃███████████              main 1  this 0  -1
1500-2000 ms  ░░░░░░░░░░░┃              main 0  this 1  +1
2000-2500 ms  ┃███████████              main 1  this 0  -1
3000-3500 ms  ████████████░░░░░░░░░░░┃  main 1  this 2  +1
📜 Previous results (5)

5653844

Sat, 01 Aug 2026 06:46:38 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1299 (+92%) 🔻 1363 🔴 (+35%) 🔻 1374 🔴 (+33%) 🔻 1433 🔴 (-9.2%) 30
TTFS stream 390 (-57%) 💚 1340 🔴 (+43%) 🔻 1370 🔴 (+44%) 🔻 1415 🔴 (+46%) 🔻 30
TTFS hook + stream 1507 (+219%) 🔻 1619 🔴 (+24%) 🔻 1776 🔴 (+33%) 🔻 1879 🔴 (+37%) 🔻 30
STSO 1020 steps (inline) 173 (+34%) 🔻 476 (+4.8%) 539 (+6.1%) 716 (±0%) 1016
STSO 1020 steps (queue-hop) 2797 (+88%) 🔻 3426 (+8.6%) 3426 (+8.6%) 3426 (+8.6%) 3
WO 1020 steps 414933 (+6.4%) 414933 (+6.4%) 414933 (+6.4%) 414933 (+6.4%) 1
SL stream latency 114 (+52%) 🔻 226 🔴 (+97%) 🔻 368 🔴 (+190%) 🔻 380 🔴 (+12%) 30
SO stream overhead (text) 102 (+2.0%) 163 (+20%) 🔻 190 (+12%) 307 (+40%) 🔻 30
SO stream overhead (structured) 107 (+4.9%) 156 (-18%) 💚 187 (-20%) 💚 287 (-56%) 💚 30

423983d

Sat, 01 Aug 2026 00:42:35 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 384 (-43%) 💚 1294 🔴 (+29%) 🔻 1334 🔴 (+30%) 🔻 1367 🔴 (-13%) 30
TTFS stream 1236 (+37%) 🔻 1284 🔴 (+37%) 🔻 1309 🔴 (+38%) 🔻 1545 🔴 (+60%) 🔻 30
TTFS hook + stream 485 (+2.5%) 1552 🔴 (+19%) 🔻 1696 🔴 (+27%) 🔻 1848 🔴 (+35%) 🔻 30
STSO 1020 steps (inline) 150 (+16%) 🔻 438 (-3.5%) 496 (-2.4%) 683 (-4.7%) 1016
STSO 1020 steps (queue-hop) 1406 (-5.7%) 3140 (-0.5%) 3140 (-0.5%) 3140 (-0.5%) 3
WO 1020 steps 377946 (-3.1%) 377946 (-3.1%) 377946 (-3.1%) 377946 (-3.1%) 1
SL stream latency 102 (+36%) 🔻 156 🔴 (+36%) 🔻 174 🔴 (+37%) 🔻 384 🔴 (+13%) 30
SO stream overhead (text) 101 (+1.0%) 142 (+4.4%) 166 (-2.4%) 177 (-19%) 💚 30
SO stream overhead (structured) 99 (-2.9%) 163 (-14%) 194 (-17%) 💚 258 (-60%) 💚 30

84c59b7

Fri, 31 Jul 2026 22:31:19 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 430 (-36%) 💚 1277 🔴 (+27%) 🔻 1313 🔴 (+27%) 🔻 1377 🔴 (-13%) 30
TTFS stream 1222 (+35%) 🔻 1276 🔴 (+36%) 🔻 1306 🔴 (+37%) 🔻 1321 🔴 (+37%) 🔻 30
TTFS hook + stream 709 (+50%) 🔻 1537 🔴 (+18%) 🔻 1624 🔴 (+21%) 🔻 1678 🔴 (+22%) 🔻 30
STSO 1020 steps (inline) 166 (+29%) 🔻 449 (-1.1%) 502 (-1.2%) 689 (-3.9%) 1016
STSO 1020 steps (queue-hop) 2084 (+40%) 🔻 3490 (+11%) 3490 (+11%) 3490 (+11%) 3
WO 1020 steps 390346 (±0%) 390346 (±0%) 390346 (±0%) 390346 (±0%) 1
SL stream latency 86 (+15%) 146 🔴 (+27%) 🔻 156 🔴 (+23%) 🔻 170 🔴 (-50%) 💚 30
SO stream overhead (text) 92 (-8.0%) 156 (+15%) 177 (+4.1%) 234 (+6.8%) 30
SO stream overhead (structured) 92 (-9.8%) 131 (-31%) 💚 143 (-39%) 💚 249 (-62%) 💚 30

d738ff4

Fri, 31 Jul 2026 22:07:57 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 284 (-58%) 💚 1342 🔴 (+33%) 🔻 1395 🔴 (+35%) 🔻 1759 🔴 (+11%) 30
TTFS stream 217 (-76%) 💚 1334 🔴 (+42%) 🔻 1354 🔴 (+43%) 🔻 1423 🔴 (+47%) 🔻 30
TTFS hook + stream 344 (-27%) 💚 1574 🔴 (+21%) 🔻 1672 🔴 (+25%) 🔻 1828 🔴 (+33%) 🔻 30
STSO 1020 steps (inline) 185 (+43%) 🔻 499 (+9.9%) 553 (+8.9%) 785 (+9.5%) 1016
STSO 1020 steps (queue-hop) 1680 (+13%) 3302 (+4.6%) 3302 (+4.6%) 3302 (+4.6%) 3
WO 1020 steps 434735 (+11%) 434735 (+11%) 434735 (+11%) 434735 (+11%) 1
SL stream latency 113 (+51%) 🔻 174 🔴 (+51%) 🔻 264 🔴 (+108%) 🔻 304 🔴 (-11%) 30
SO stream overhead (text) 127 (+27%) 🔻 228 (+68%) 🔻 294 (+73%) 🔻 358 (+63%) 🔻 30
SO stream overhead (structured) 123 (+21%) 🔻 213 (+12%) 270 (+15%) 423 (-35%) 💚 30

32cac72

Fri, 31 Jul 2026 17:01:46 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1270 (+143%) 🔻 1318 🔴 (+27%) 🔻 1326 🔴 (+24%) 🔻 1341 🔴 (-17%) 💚 30
TTFS stream 1249 (+32%) 🔻 1305 🔴 (+30%) 🔻 1325 🔴 (+29%) 🔻 1348 🔴 (+4.6%) 30
TTFS hook + stream 489 (+16%) 🔻 1572 🔴 (+18%) 🔻 1584 🔴 (+17%) 🔻 1612 🔴 (-8.9%) 30
STSO 1020 steps (inline) 150 (-8.0%) 466 (-1.7%) 515 (-3.0%) 727 (+8.3%) 1016
STSO 1020 steps (queue-hop) 1555 (-25%) 💚 3258 (+1.8%) 3258 (+1.8%) 3258 (+1.8%) 3
WO 1020 steps 389878 (-3.7%) 389878 (-3.7%) 389878 (-3.7%) 389878 (-3.7%) 1
SL stream latency 80 (-2.4%) 143 🔴 (+8.3%) 179 🔴 (+22%) 🔻 299 🔴 (+39%) 🔻 30
SO stream overhead (text) 94 (-6.9%) 160 (-7.0%) 195 (-8.9%) 236 (-5.6%) 30
SO stream overhead (structured) 103 (-6.4%) 160 (-5.3%) 194 (+2.1%) 3374 🔴 (+1380%) 🔻 30
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1466 0 239 1705
✅ 💻 Local Development 1633 0 227 1860
✅ 📦 Local Production 1633 0 227 1860
✅ 🐘 Local Postgres 1633 0 227 1860
✅ 🪟 Windows 155 0 0 155
✅ 📋 Other 1028 0 212 1240
✅ vercel-multi-region 27 0 0 27
Total 7575 0 1132 8707
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 127 0 28
✅ example 127 0 28
✅ express 127 0 28
✅ fastify 127 0 28
✅ hono 127 0 28
✅ nextjs-turbopack 152 0 3
✅ nextjs-webpack 152 0 3
✅ nitro 127 0 28
✅ nuxt 127 0 28
✅ sveltekit 146 0 9
✅ vite 127 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 155 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 129 0 26
✅ e2e-local-dev-tanstack-start- 129 0 26
✅ e2e-local-postgres-nest-stable 129 0 26
✅ e2e-local-postgres-tanstack-start- 129 0 26
✅ e2e-local-prod-nest-stable 129 0 26
✅ e2e-local-prod-tanstack-start- 129 0 26
✅ e2e-vercel-prod-nest 127 0 28
✅ e2e-vercel-prod-tanstack-start 127 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@socket-security

socket-security Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​devalue@​5.9.010010010092100

View full report

@TooTallNate
TooTallNate requested review from a team, NathanColosimo and VaguelySerious July 31, 2026 22:08
@VaguelySerious

Copy link
Copy Markdown
Member

All of the nextjs tests seem to be failing consistently 🤔

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.
@TooTallNate

Copy link
Copy Markdown
Member Author

Fixed in 8bc462f — thanks, this was a real bug in the PR, not flake.

Cause. Both failures were the webhook tests, and the 404 was downstream: resumeWebhook could not serialize its step return value ("Cannot stringify arbitrary non-POJOs"), so the hook was never registered and the POST had nothing to hit. The value is a NextRequest, which Next.js hands over as a Proxy — and isInstanceOfPrototype rejected proxies outright, so the Request reducer answered "not a Request" and devalue fell through to the POJO check.

Why I got it wrong. I had reasoned (and asserted in a review reply) that proxied built-ins were never serializable, since internal-slot reads throw on a proxy receiver. That holds 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. My generalization was too broad.

Fix. Identification now walks through proxies, matching instanceof, and records the traps instead of suppressing the answer. The three reducers that genuinely read internal slots (URL, URLSearchParams, Headers) fall back to the dynamic read when the value is a proxy, so their behavior is byte-for-byte what it was before — including throwing for a bare proxy over a built-in, which threw before too.

Verification. Reproduced locally first, then confirmed by reverting serialization.ts to main (webhook test went 60s-timeout → 5s pass) before fixing. The full nextjs-turbopack e2e suite now passes locally: 135/135. Unit suite 1766 passing. Added a regression test using a receiver-correcting proxy — the shape that makes NextRequest work — with a comment recording that a bare new Proxy(request, {}) throws on undici's private slots both before and after this change.

Also merged main in and re-verified afterwards.

Comment thread packages/core/src/serialization/hardened.ts Outdated

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: no blocking issues

Comment thread packages/core/src/step.ts Outdated
Comment thread packages/core/src/serialization.ts
- `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.
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for b732e91 (AI decision).

This is a large feat(core) rewrite of the serialization introspection layer that adds new API surface (CodecOptions.guestCodeStats, exported GuestCodeStats/GuestCodeExecution types, new optional out-params on dehydrateStepArguments/dehydrateWorkflowReturnValue, two new OTel semantic conventions) and requires a devalue 5.8.15.9.0 bump plus a workspace override change. It is explicitly preventative hardening for future VM-retention work rather than a fix for a defect users hit on stable — the PR itself notes the hazard "survives only because the VM is destroyed immediately after serializing" and "becomes load-bearing the moment a VM is retained across steps." The one genuine regression fix inside it (proxied host classes like Next.js's NextRequest no longer being identified, breaking webhooks) is a regression introduced by this same PR, so it does not exist on stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

b732e91fac77e0f445349aefa8bdeac5b8b77e20

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants