Skip to content

feat(core): widen retained boundaries to plain data and standard built-ins - #3047

Merged
NathanColosimo merged 2 commits into
nathanc/retained-vm-corefrom
nathanc/retained-vm-passivity
Aug 3, 2026
Merged

feat(core): widen retained boundaries to plain data and standard built-ins#3047
NathanColosimo merged 2 commits into
nathanc/retained-vm-corefrom
nathanc/retained-vm-passivity

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Third of the 3-PR stack from #2990 — replaces #3046's primitives-only retention gate with the guest-code report from #3257's hardened serialization (now on main; independently simplified in #3288).

  • handleSuspension passes a GuestCodeStats sink into dehydrateStepArguments (the out-param feat(core): side-effect-free serialization of workflow VM values #3257 added for exactly this) and retains the boundary only when executions stays empty — i.e. serializing the step inputs provably executed no workflow code.
  • One extra probe covers the single execution path the sink cannot see: V8's engine stack getter is treated as engine-provided by the serializer, but it calls a workflow-defined Error.prepareStackTrace when one is set. The gate walks the VM realm's Error chain descriptor-wise (never invoking anything) and demotes if a formatter is present.
  • Net effect: plain data and standard built-ins (Map, Set, Date, RegExp, Error, typed arrays, URL, Headers) keep the fast path — including under prototype patching and polyfills, since serialization reads them through captured intrinsics. Getters, proxies, custom [WORKFLOW_SERIALIZE] serializers, and step-function closures demote that one boundary to ordinary replay, exactly like the pre-retention runtime.

Why this is safe (one invariant): step-argument serialization runs once at suspension and never re-runs during replay, so its only hazard is executing workflow code whose side effects live on in the retained VM. There is a single serialization path shared by all modes, so bytes cannot differ by mode; the report is a ground-truth record of whether workflow code ran, not a prediction.

An earlier revision of this PR carried its own hardened-serialization implementation (operations.ts, a devalue patch, reducer rewrites — ~2,600 lines). All of that landed via #3257 (with a follow-up simplification split out as #3288), so this PR is now just the gate: ~40 lines of runtime change plus tests.

Merge order: depends on #3288

The gate trusts the guest-code sink to record every workflow-code execution. isEngineAccessor on main still has the documented bound-function hole (fn.bind() stringifies as native code, so a bound getter executes with an empty report) — #3288 closes it. Land #3288 before this PR; until then the sink is telemetry-grade, not gate-grade, for bound getters specifically.

Panel review (Codex gpt-5.6-sol xhigh + Claude Fable 5 xhigh) drove three fixes now included: the Error.prepareStackTrace probe no longer throws on a clobbered Error binding (it demotes; a throw here would wedge the run on redelivery), the probe runs before dehydration as well as after (a formatter that runs during a stack read can delete itself), and the reducer guards' global[name]/ctor.prototype lookups now go through descriptor reads so an accessor planted on a sandbox global is recorded instead of silently executed. The __closureVarsFn laundering caveat (deliberate use of the internal WORKFLOW_USE_STEP symbol) remains accepted-by-design, as documented in hardened.ts; the docs no longer claim step closures demote.

Review focus

The gate computation in suspension-handler.ts (sink + replacedPrepareStackTrace probe). Everything else is tests and docs.

Validation

Full core unit suite green: 80 files, 1806 passed, 3 expected fails. Loop-level coverage proves built-in/polyfilled/patched-prototype args retain (vmBuilds === 1) while getter/proxy/custom-serializer/prepareStackTrace cases demote (vmBuilds > 1) with byte-identical output to a cold replay.

Docs Preview

Page Link
Runtime tuning — WORKFLOW_RETAINED_VM /v5/docs/configuration/runtime-tuning#workflow_retained_vm

Stack: #3045#3046 → this. #2990 stays open as the reference implementation.

@NathanColosimo
NathanColosimo requested review from a team and ijjk as code owners July 22, 2026 02:37
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3f7f12f

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 Minor
workflow Minor
@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

@vercel

vercel Bot commented Jul 22, 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 3, 2026 8:45pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 3, 2026 8:45pm
example-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-astro-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-express-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-fastify-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-hono-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-nestjs-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-nitro-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-nuxt-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-sveltekit-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workbench-vite-workflow Ready Ready Preview Aug 3, 2026 8:45pm
workflow-docs Ready Ready Preview, v0 Aug 3, 2026 8:45pm
workflow-swc-playground Ready Ready Preview Aug 3, 2026 8:45pm
workflow-tarballs Ready Ready Preview Aug 3, 2026 8:45pm
workflow-web Ready Ready Preview Aug 3, 2026 8:45pm

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1477 0 239 1716
✅ 💻 Local Development 1645 0 227 1872
✅ 📦 Local Production 1645 0 227 1872
✅ 🐘 Local Postgres 1645 0 227 1872
✅ 🪟 Windows 156 0 0 156
✅ 📋 Other 1036 0 212 1248
✅ vercel-multi-region 27 0 0 27
Total 7631 0 1132 8763
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 128 0 28
✅ example 128 0 28
✅ express 128 0 28
✅ fastify 128 0 28
✅ hono 128 0 28
✅ nextjs-turbopack 153 0 3
✅ nextjs-webpack 153 0 3
✅ nitro 128 0 28
✅ nuxt 128 0 28
✅ sveltekit 147 0 9
✅ vite 128 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 130 0 26
✅ express-stable 130 0 26
✅ fastify-stable 130 0 26
✅ hono-stable 130 0 26
✅ nextjs-turbopack-canary 137 0 19
✅ nextjs-turbopack-stable 156 0 0
✅ nextjs-webpack-canary 137 0 19
✅ nextjs-webpack-stable 156 0 0
✅ nitro-stable 130 0 26
✅ nuxt-stable 130 0 26
✅ sveltekit-stable 149 0 7
✅ vite-stable 130 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 130 0 26
✅ express-stable 130 0 26
✅ fastify-stable 130 0 26
✅ hono-stable 130 0 26
✅ nextjs-turbopack-canary 137 0 19
✅ nextjs-turbopack-stable 156 0 0
✅ nextjs-webpack-canary 137 0 19
✅ nextjs-webpack-stable 156 0 0
✅ nitro-stable 130 0 26
✅ nuxt-stable 130 0 26
✅ sveltekit-stable 149 0 7
✅ vite-stable 130 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 130 0 26
✅ express-stable 130 0 26
✅ fastify-stable 130 0 26
✅ hono-stable 130 0 26
✅ nextjs-turbopack-canary 137 0 19
✅ nextjs-turbopack-stable 156 0 0
✅ nextjs-webpack-canary 137 0 19
✅ nextjs-webpack-stable 156 0 0
✅ nitro-stable 130 0 26
✅ nuxt-stable 130 0 26
✅ sveltekit-stable 149 0 7
✅ vite-stable 130 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 156 0 0

✅ 📋 Other

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

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@NathanColosimo
NathanColosimo marked this pull request as draft July 22, 2026 05:15
@NathanColosimo
NathanColosimo force-pushed the nathanc/retained-vm-passivity branch from 6590434 to 1695747 Compare July 22, 2026 18:10

@vercel vercel Bot 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.

Additional Suggestion:

3 tests in packages/core/src/serialization/serialization.test.ts call require('devalue'), but devalue was removed as a dependency and vendored, so these require calls fail with Cannot find module 'devalue'.

Fix on Vercel

… of primitives-only args

Replaces the isPrimitiveStepArgument allowlist with the GuestCodeStats sink
that dehydrateStepArguments already exposes: a boundary retains unless
serializing its step inputs actually executed workflow code (getters, proxy
traps, custom serializers), plus a descriptor-walk probe for a replaced
Error.prepareStackTrace — the one execution path the sink cannot see,
because the serializer treats V8's engine stack getter as engine-provided.

Plain data and standard built-ins (Map, Set, Date, RegExp, Error, typed
arrays, URL, Headers) now stay on the fast path, including under prototype
patching and polyfills, since serialization reads them through captured
intrinsics.

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

Reviewed at head as the incremental diff over #3046 (+312/−80).

The #3288 dependency is satisfied — but verify via rebase before merging. This branch predates #3288, so CI on the current head validates the gate against the pre-#3288 sink (the one with the bound-getter reporting hole your own body says is telemetry-grade only). I closed that gap locally: test-merged this branch with post-#3288 main, full build green, complete core suite 1818 passed / 3 expected fail, and the retention-relevant files green under WORKFLOW_RETAINED_VM=0 too. recordGuestCode survived #3288's hardened.ts rewrite with a compatible signature. Still, please rebase (or merge main) so CI blesses the actual shipped combination.

Verified in the code:

  • The gate is now a ground-truth report (computed after settlePhase(ops), so the sink is final), not a prediction — the right shape. Per-step sinks merged into the batch sink, with the span-attribution rationale documented at the merge site.
  • Sink concurrency is safe: guest code only executes inside withGuestCodeStats's synchronous window (save/restore around a sync fn()); the parallel step dehydrations interleave only at await points, which sit outside the sink windows. No cross-attribution.
  • getHostClassPrototype descriptor-read hardening + once() per reducer set: an accessor planted on a sandbox global is recorded instead of silently executed, and the read lands exactly once per pass inside the sink scope.
  • readErrorStack is the strongest part: recording any accessor-backed stack read (not probing for a formatter) correctly captures both hazards — prepareStackTrace execution and the workflow-visible format-and-cache — and the self-deleting-formatter test proves why presence-probing would have been insufficient.

One ask — update the PR body. It describes an older design: a replacedPrepareStackTrace probe in suspension-handler.ts that "walks the VM realm's Error chain descriptor-wise" and runs "before dehydration as well as after". None of that exists in the final code — readErrorStack in reducers/common.ts replaced it. Consequently the body's claim that Error "keeps the fast path" is wrong for the shipped code: a natural V8 error's lazy stack accessor always records, so natural Error arguments demote (only rehydrated/data-property-stack errors retain). The changeset and the runtime-tuning docs already state this correctly ("or computed an Error's stack trace"), and the tests assert it (reports unsafe for an Error argument) — it's only the body that's stale, and reviewers/archaeologists will read it.

Loop-level coverage is exactly what I'd want: built-ins retain, core-js-style polyfills retain (data-valued additions), patched toISOString retains (captured intrinsics), getter/proxy/prepareStackTrace/self-deleting-formatter demote — all with byte-identical output to cold replay.

CI green. Approving; the rebase + body refresh are pre-merge housekeeping, not design concerns.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for 89ede82 (AI decision).

This is feature work: it widens the retained-VM fast path from primitives-only to plain data and standard built-ins, replacing the retention gate with a guest-code sink, and ships as a minor changeset with a feat(core): subject. It is also the third PR of a stack that explicitly builds on hardened-serialization APIs (GuestCodeStats, the guestCodeStatsOut out-param) introduced only on main. Nothing here fixes a user-visible defect on the maintenance line, so it should stay on main.

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

89ede82faa25143a150edcd088cd48b6d34daf3d

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.

2 participants