Skip to content

QuickJS engine: baseline-snapshot startup optimization (~25× faster VM boot) - #3342

Open
TooTallNate wants to merge 6 commits into
quickjs-host-serdefrom
quickjs-baseline-snapshot
Open

QuickJS engine: baseline-snapshot startup optimization (~25× faster VM boot)#3342
TooTallNate wants to merge 6 commits into
quickjs-host-serdefrom
quickjs-baseline-snapshot

Conversation

@TooTallNate

Copy link
Copy Markdown
Member

Stacked on #3263 (uses the host-serde engine's per-VM intrinsics capture, which re-runs cleanly against a restored heap).

Problem

Evaluating the workflow bundle dominates QuickJS VM startup — ~74ms of a ~77ms boot for the real generated e2e flow bundle (1.3MB, 154 workflows) — and full event replay pays it on every invocation, not just the first. This is a large share of the engine's TTFS gap vs node:vm, where V8 compiles the same script in single-digit milliseconds.

Approach

The bundle is identical across all runs of a deployment. The engine now hydrates one VM per function instance (bootstrap + bundle eval), snapshots its memory (quickjs-wasi snapshot()/restore()), and starts every invocation by restoring the snapshot instead of re-evaluating:

boot → first suspension
fresh eval (before / kill-switched) 79.4 ms
restore (after, warm) 3.2 ms (24.8×)
first invocation (hydrate + restore) 85.8 ms — ≈ one fresh boot, amortized

Every replay wake gets the discount, not just run start.

Determinism

Replay requires module-scope user code to observe the run-seeded PRNG and the deterministic clock; a restored heap carries whatever module scope computed at hydrate time. Two safeguards:

  1. Hydrate gate: the hydrate runs with draw-counting placeholder host fns and a read-counting WASI clock. A bundle whose module scope consumed either is marked ineligible — every invocation falls back to fresh evaluation with exact node:vm-parity semantics. (Module-scope eval failures also gate out, so the fresh path surfaces the real, source-mapped error.) Because eligible bundles consumed neither, fresh and restored invocations are interchangeable even within one run — safe for mixed-fleet rollout.
  2. Name-keyed host-fn re-registration: the per-run random/__generateNanoid/__generateUlid callbacks re-register by NAME on the restored VM before the workflow body runs (quickjs-wasi restore semantics), so the seeded draw sequence — and every correlationId — is byte-identical to fresh eval. Pinned by a parity test that feeds Math.random() into a step input and byte-compares serialized pending ops across fresh, first-restore, and cached-restore invocations.

Mechanics

  • Cache per function instance, keyed on the bundle string (reference-stable in generated flow routes), promise-deduped for concurrent first invocations, capped at 4 entries (snapshots are ~16 MiB each)
  • Hydrate rejections (infra) evict for retry; eval failures cache as ineligible
  • Kill switch: WORKFLOW_QUICKJS_BASELINE_SNAPSHOT=0

Testing

  • 5 new tests: fresh/restore byte parity, full replay through restore, and the three gates (PRNG draw, clock read, module-scope throw)
  • The existing multi-invocation runtime tests now exercise the restore path implicitly (cache on by default)
  • Core suite 1966 passed / 3 xfail; full e2e 137/137 under WORKFLOW_VM=quickjs with the snapshot path live

Follow-ups

  • Build-time snapshot embedding (option 2 from the design discussion): same runtime win, only moves the one-time hydrate earlier; needs builder changes + snapshot-format/version coupling. The gzipped snapshot is 4.1 MiB.
  • The QuickJS engine: threshold-based VM-memory snapshotting (WORKFLOW_SNAPSHOT_THRESHOLD) #3251 threshold-snapshot stack (mid-run snapshots) composes with this: its restore path re-registers the same host-fn names; rebasing that branch onto this one should be mechanical.

Evaluating the workflow bundle dominates VM startup (~74ms of a ~77ms
boot for the 1.3MB e2e bundle) and full event replay pays it on EVERY
invocation — a large share of the quickjs engine's TTFS gap vs node:vm,
where V8 compiles the same script in single-digit ms. The bundle is
identical across all runs of a deployment, so the engine now hydrates
one VM per function instance (bootstrap + bundle eval), snapshots its
memory, and starts every invocation with QuickJS.restore (~3ms) instead
of re-evaluating.

Measured on the real generated e2e flow bundle (154 workflows), boot to
first suspension: fresh 79.4ms -> restored 3.2ms (24.8x). First
invocation pays hydrate+restore (85.8ms, ~= one fresh boot); every
subsequent invocation — including every replay wake — gets the
discount.

Determinism: replay requires module-scope user code to observe the
run-seeded PRNG and deterministic clock, and a restored heap carries
whatever module scope computed at hydrate time. The hydrate therefore
runs with draw-counting placeholder host fns and a read-counting clock;
a bundle that consumed either is marked ineligible and every invocation
falls back to fresh evaluation (node:vm-parity semantics preserved
exactly). When the gate passes, restore is byte-equivalent to fresh
eval: the per-run host fns (random / __generateNanoid / __generateUlid)
re-register by NAME on the restored VM before the workflow body runs,
so the seeded draw sequence — and every correlationId — is identical.
Pinned by a parity test that feeds Math.random() into a step input and
byte-compares the serialized ops across fresh, first-restore and
cached-restore invocations.

Cache: per function instance, keyed on the bundle string
(reference-stable in generated flow routes), promise-deduped for
concurrent first invocations, capped at 4 entries; hydrate rejections
evict for retry while eval failures cache as ineligible (the fresh path
re-evaluates and surfaces the real, source-mapped error).

Kill switch: WORKFLOW_QUICKJS_BASELINE_SNAPSHOT=0.
@TooTallNate
TooTallNate requested a review from a team as a code owner August 5, 2026 00:15
Copilot AI review requested due to automatic review settings August 5, 2026 00:15
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7f93aef

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/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow 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 Aug 5, 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 5, 2026 11:59pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 5, 2026 11:59pm
example-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-astro-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-express-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-fastify-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-hono-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-nestjs-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-nitro-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-nuxt-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-sveltekit-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workbench-vite-workflow Ready Ready Preview Aug 5, 2026 11:59pm
workflow-docs Ready Ready Preview, v0 Aug 5, 2026 11:59pm
workflow-swc-playground Ready Ready Preview Aug 5, 2026 11:59pm
workflow-tarballs Ready Ready Preview Aug 5, 2026 11:59pm
workflow-web Ready Ready Preview Aug 5, 2026 11:59pm

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (1 failed)

nextjs-turbopack-node (1 failed):

  • distributedAbortController - reconnect to existing controller | wrun_41KZA6VRJ70GG4APG22Y832ST6 | 🔍 observability

💻 Local Development (1 failed)

nextjs-webpack-canary-node (1 failed):

  • AbortController abortThrowIfAbortedWorkflow: throwIfAborted causes FatalError, no retries

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 2931 1 500 3432
❌ 💻 Local Development 3152 1 435 3588
✅ 📦 Local Production 3290 0 454 3744
✅ 🐘 Local Postgres 3290 0 454 3744
✅ 🪟 Windows 312 0 0 312
✅ 📋 Other 2068 0 428 2496
✅ vercel-multi-region 27 0 0 27
Total 15070 2 2271 17343
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 127 0 29
✅ astro-quickjs 127 0 29
✅ example-node 127 0 29
✅ example-quickjs 127 0 29
✅ express-node 127 0 29
✅ express-quickjs 127 0 29
✅ fastify-node 127 0 29
✅ fastify-quickjs 127 0 29
✅ hono-node 127 0 29
✅ hono-quickjs 127 0 29
❌ nextjs-turbopack-node 151 1 4
✅ nextjs-turbopack-quickjs 152 0 4
✅ nextjs-webpack-node 152 0 4
✅ nextjs-webpack-quickjs 152 0 4
✅ nitro-node 127 0 29
✅ nitro-quickjs 127 0 29
✅ nuxt-node 127 0 29
✅ nuxt-quickjs 127 0 29
✅ sveltekit-node 146 0 10
✅ sveltekit-quickjs 146 0 10
✅ vite-node 127 0 29
✅ vite-quickjs 127 0 29

❌ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 156 0 0
✅ nextjs-turbopack-quickjs 156 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable-node 130 0 26
✅ e2e-local-dev-nest-stable-quickjs 130 0 26
✅ e2e-local-dev-tanstack-start-node 130 0 26
✅ e2e-local-dev-tanstack-start-quickjs 130 0 26
✅ e2e-local-postgres-nest-stable-node 130 0 26
✅ e2e-local-postgres-nest-stable-quickjs 130 0 26
✅ e2e-local-postgres-tanstack-start-node 130 0 26
✅ e2e-local-postgres-tanstack-start-quickjs 130 0 26
✅ e2e-local-prod-nest-stable-node 130 0 26
✅ e2e-local-prod-nest-stable-quickjs 130 0 26
✅ e2e-local-prod-tanstack-start-node 130 0 26
✅ e2e-local-prod-tanstack-start-quickjs 130 0 26
✅ e2e-vercel-prod-nest-node 127 0 29
✅ e2e-vercel-prod-nest-quickjs 127 0 29
✅ e2e-vercel-prod-tanstack-start-node 127 0 29
✅ e2e-vercel-prod-tanstack-start-quickjs 127 0 29

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

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.

Pull request overview

Implements a QuickJS runtime startup optimization by caching a baseline VM-memory snapshot of a bundle-hydrated VM and restoring it for subsequent invocations, significantly reducing per-invocation boot overhead while preserving replay determinism via eligibility gating and host-fn re-registration.

Changes:

  • Add baseline-snapshot hydrate/restore path in the QuickJS runtime, keyed by workflow bundle and gated against module-scope nondeterminism (PRNG/clock).
  • Add unit tests covering fresh-vs-restore byte parity, full replay through restore, and ineligibility gates.
  • Introduce an env-var kill switch (WORKFLOW_QUICKJS_BASELINE_SNAPSHOT=0) and ship a changeset for @workflow/core.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
packages/core/src/runtime/quickjs-runtime.ts Adds baseline snapshot cache + hydrate/restore logic and deterministic clock wiring for restores.
packages/core/src/runtime/quickjs-runtime.test.ts Adds a focused test suite validating restore parity, replay completion, and gating behavior.
packages/core/src/runtime/constants.ts Adds isQuickJSBaselineSnapshotEnabled() kill switch helper.
.changeset/quickjs-baseline-snapshot.md Publishes the runtime optimization as a patch-level changeset for @workflow/core.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1079 to +1083
/**
* WASI clock override reading the given accessor — shared between fresh
* boots (initWorkflowVM) and baseline-snapshot restores, so both paths
* expose the same deterministic replay clock.
*/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

initWorkflowVM now calls the shared makeDeterministicClockWasi helper (the helper was hoisted above it), so the doc claim is true and the two implementations cannot drift. (a2adae0)

Comment thread packages/core/src/runtime/quickjs-runtime.ts
Comment on lines +1187 to +1199
__clearBaselineSnapshotCacheForTests();
process.env.WORKFLOW_QUICKJS_BASELINE_SNAPSHOT = '0';
let fresh: Awaited<ReturnType<typeof runQuickJSWorkflow>>;
try {
fresh = await runQuickJSWorkflow({
workflowCode: stepRaceCode,
workflowId: 'workflow//test//workflow',
workflowRun: makeRun(),
events: [],
});
} finally {
delete process.env.WORKFLOW_QUICKJS_BASELINE_SNAPSHOT;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — the test now saves any pre-existing value and restores it (or deletes only when originally unset). (a2adae0)

Comment on lines +301 to +305
/**
* Whether the QuickJS engine's baseline-snapshot startup optimization is
* enabled (default ON). When on, the engine hydrates a VM with the
* workflow bundle once per function instance, snapshots it, and starts
* every invocation by restoring the snapshot instead of re-evaluating

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — the baseline-snapshot block moved below isTurboEnabled, so the WORKFLOW_TURBO JSDoc is reattached to its function. (a2adae0)

Comment on lines +1350 to +1356
const vm = baselineSnapshot
? await QuickJS.restore(baselineSnapshot, {
wasm: (await getCompiledAssets()).wasm as never,
memoryLimit: 256 * 1024 * 1024,
interruptHandler: createInterruptHandler(interruptBudget),
extensions: (await getCompiledAssets()).extensions,
wasi: makeDeterministicClockWasi(() => vmNowMs),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — single assets local in the restore branch (restructured to an if/else so the await sits outside the options object). (a2adae0)

Comment thread packages/core/src/runtime/quickjs-runtime.ts Outdated
Comment thread packages/core/src/runtime/quickjs-runtime.ts Outdated
…, shared clock helper, review nits

- Serialization-intrinsics gate (the substantive finding): the restore
  path's serde captures intrinsics from the restored heap — AFTER module
  scope ran — while the fresh path captures before user code. A bundle
  that replaced a captured intrinsic at module scope (e.g. a
  Date.prototype.toISOString polyfill) without touching PRNG/clock
  passed the eligibility gate yet would serialize differently on the two
  paths. captureIntrinsicsSignature (exported from quickjs-serde)
  identity-fingerprints every to-be-captured value; the hydrate compares
  it before and after bundle eval and marks any replacement ineligible.
  Expression-created entries (makeSparseArray, makeThunk, hasOwnCall)
  are excluded — they get fresh identities per eval and cannot be
  replaced by user code. Gate test added with a toISOString polyfill.
- Hydrate-failure fallback: a getBaselineEntry rejection (infrastructure
  — vm.snapshot() under memory pressure, QuickJS.create failing) no
  longer fails the invocation; it logs a warning and falls back to fresh
  evaluation, with the cached promise already evicted for retry.
- initWorkflowVM now uses the shared makeDeterministicClockWasi helper
  its doc claimed it shared, so the two clock implementations cannot
  drift.
- getCompiledAssets() awaited once per call site (hydrate + restore).
- WORKFLOW_TURBO JSDoc reattached to isTurboEnabled (the baseline
  constant had been inserted between doc and function).
- Parity test saves/restores any pre-existing
  WORKFLOW_QUICKJS_BASELINE_SNAPSHOT env value instead of deleting it.

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

Focused review: one reproducible snapshot-equivalence blocker.

* path. Missing captures (absent extension globals) signature as -1.
*/
export function captureIntrinsicsSignature(vm: QuickJS): number[] {
using captured = vm.evalCode(CAPTURE_INTRINSICS);

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.

[P1 blocker] The eligibility probe can execute user-patched capture helpers while still classifying the bundle as snapshot-safe. CAPTURE_INTRINSICS calls Object.getOwnPropertyDescriptor and Object.getPrototypeOf, but those helpers are not themselves included in the identity signature. I reproduced this with module scope wrapping Object.getOwnPropertyDescriptor in a forwarding function that increments a counter, then returning that counter from the workflow. The gate reported ready; fresh evaluation returned 0, while snapshot restore returned 24 because the post-eval probe and restore-time serde capture mutated module state. This violates fresh/restore equivalence and can change durable output during mixed-fleet rollout. Please include every helper used to construct the capture in the signature—at least Object.getOwnPropertyDescriptor and Object.getPrototypeOf—or compute the post-eval signature exclusively through pre-user-code host-held handles, and add this forwarding-wrapper regression test.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Excellent repro — and it exposed that the gate approach was structurally losing, not just incomplete. Adding Object.getOwnPropertyDescriptor/getPrototypeOf to the signature would have patched YOUR wrapper, but any probe that executes guest-reachable code post-eval both (a) needs its own dependencies gated recursively and (b) bakes its side effects into the snapshot even when the gate passes (stateful getters on captured properties being the next hole in line).

Fixed in 7f93aef by prevention instead of detection: all guest-touching serde initialization (intrinsics capture, branded samples, symbol lookups) is bundled into one CAPTURE_ROOT expression evaluated in the baseline VM before the bundle — the same capture-before-user-code ordering the fresh path has always had. The container's handle box lives in the snapshot's linear memory, its raw pointer rides the BaselineEntry, and every restored VM re-adopts it by pointer (adoptSerdeRoot) — serde init then performs only plain-data property reads and C-level classId reads, so no guest code executes after user code has run, on either path. Your counter repro is now a regression test asserting fresh === restore === 0. The identity-signature gate is deleted outright (module-scope intrinsic patching is now harmless, not merely detectable — polyfill bundles become ELIGIBLE and byte-identical across paths), and process.env injection moved from guest-source eval to handle-based install for the same reason (its JSON.parse ran post-eval on the restore path only).

…e64) into quickjs-baseline-snapshot

The new escapeString captured intrinsic is expression-created (a fresh
guest closure per capture eval), so it joins makeSparseArray/makeThunk/
hasOwnCall in captureIntrinsicsSignature's exclusion list — without
this the baseline hydrate gate would classify every bundle ineligible
(the byte-parity test catches exactly that, as it did when hasOwnCall
was missed).
Comment thread packages/core/src/runtime/quickjs-serde.ts Outdated
…the snapshot

The intrinsics-replacement gate was structurally losing: its own
post-eval probe executed guest-reachable code (CAPTURE_INTRINSICS calls
Object.getOwnPropertyDescriptor / Object.getPrototypeOf), those
dependencies were not in the identity signature, and a module-scope
stateful wrapper around them both evaded detection AND had its side
effects baked into the snapshot — fresh returned 0 from the reviewer's
counter repro while restore returned the probe's call count.

Replace detection with prevention: ALL guest-touching serde
initialization (intrinsics capture, branded samples, well-known symbol
lookups) is bundled into one CAPTURE_ROOT expression evaluated in the
baseline VM BEFORE the bundle — the same capture-before-user-code
ordering the fresh path has always had. The container handle's box
lives in the snapshot's linear memory, its raw pointer rides the
BaselineEntry, and every restored VM re-adopts it (adoptSerdeRoot) —
serde init then performs only plain-data property reads and C-level
classId reads: NO guest code executes after user code has run, on
either path.

Consequences:
- the identity-signature gate and its expression-created skip-list are
  deleted (nothing to detect — module-scope intrinsic patching is now
  HARMLESS on the snapshot path, not merely detectable)
- polyfill bundles become ELIGIBLE for the optimization and serialize
  through pristine intrinsics identically on both paths (test flipped
  from gating to byte-equality)
- process.env injection converted from guest-source eval to
  handle-based installProcessEnv (captured Object.freeze +
  vm.hostToHandle): the old evalCode ran JSON.parse post-eval on the
  restore path only, the same observable-divergence class
- the reviewer's stateful-wrapper repro is a regression test: the
  counter must be zero and identical across fresh and restored
  invocations
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.

3 participants