Skip to content

QuickJS engine: host-side, side-effect-free serialization via handles - #3263

Open
TooTallNate wants to merge 2 commits into
quickjs-vm-perffrom
quickjs-host-serde
Open

QuickJS engine: host-side, side-effect-free serialization via handles#3263
TooTallNate wants to merge 2 commits into
quickjs-vm-perffrom
quickjs-host-serde

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Jul 31, 2026

Copy link
Copy Markdown
Member

Summary

Moves the QuickJS engine's serialization entirely to the host, operating on JSValueHandles — the serde bundle previously bundled by esbuild and evaluated inside the VM is gone. This mirrors the node:vm engine's architecture (the serializer is host code reaching into the sandbox realm) and is the QuickJS counterpart to #3257's side-effect-free serialization for node:vm.

Built on:

Architecture

runtime/quickjs-serde.ts implements the workflow wire codec over handles:

  • Hybrid stringify value space: workflow reducers return host shapes with handle leaves (exactly how the node:vm codec mixes host shapes with sandbox-realm leaves); every operation dispatches on JSValueHandle and falls back to defaultStringifyOperations for host values. Parse operations are handle-only — every revived value is built inside the VM through boot-captured constructors.
  • Side-effect freedom: classification by engine brand (boot-sampled classId map, isError, isProxy) — never instanceof or Symbol.toStringTag; extraction through boot-captured intrinsics invoked with explicit receivers; property access through descriptors. Patched prototypes and spoofed brands can no longer perturb serialization (a Symbol.toStringTag: 'Date' spoof serializes as the plain object it is — the previous codec crashed on that input). The only guest code executed is what the contract always executed: WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE, __closureVarsFn, WORKFLOW_USE_STEP on revival.
  • Bootstrap simplification: pending ops carry RAW values (step input, hook metadata, abort payloads), and raw workflow results/errors; the host serializes through handles at collection time, with a per-VM byte cache for ops that stay pending across suspensions.
  • Determinism preserved: correlationId ULIDs move host-side but draw from the same seeded PRNG instance as the VM's Math.random, so the interleaved draw sequence — and every generated ID — is identical to the in-VM factory's. Existing runs replay byte-for-byte.

Wire-format parity

Event logs persist across SDK versions, so parity with the previous in-VM codec is load-bearing (old runs must replay; node-engine steps must read VM-serialized inputs). The old codec's value-space implementation is retained as serialization/workflow-vm.ts (host reference codec) and a new parity suite byte-compares against it in both directions: 42 tests covering primitives/bigints/-0/NaN, containers, typed arrays/views/buffers, the full Error family with cause chains, shared refs + cycles, null-proto objects, boxed primitives, step-function proxies (closure vars + bound this), workflow refs, symbol-stamped stream handles, registry class instances, and patched-prototype/spoof resistance.

Removed

  • scripts/build-vm-serde-bundle.js, the generated vm-serde-bundle.generated.ts, serialization/vm-bundle-entry.ts, and the bundle eval in VM init.

Testing

  • New: 42 serde parity tests
  • Existing: 16 quickjs-runtime tests green unchanged (their fixtures are built with the reference codec, so they also validate parity end to end)
  • Full @workflow/core suite: 1757 passed | 3 expected fail
  • Local e2e (WORKFLOW_VM=quickjs, dev server): hooks 26✓, AbortController 26✓, streams 7✓, retries/errors 7✓, instance methods ✓, recursive start() (fibonacci) ✓

Follow-up candidates

@TooTallNate
TooTallNate requested review from a team and ijjk as code owners July 31, 2026 18:50
Copilot AI review requested due to automatic review settings July 31, 2026 18:50
@changeset-bot

changeset-bot Bot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5b44f6b

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

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.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

❌ Failed E2E Tests

▲ Vercel Production (15 failed)

astro-quickjs (1 failed):

example-quickjs (2 failed):

express-quickjs (2 failed):

fastify-quickjs (2 failed):

hono-quickjs (1 failed):

nextjs-turbopack-quickjs (1 failed):

nextjs-webpack-quickjs (1 failed):

nitro-quickjs (1 failed):

nuxt-quickjs (2 failed):

sveltekit-quickjs (1 failed):

vite-quickjs (1 failed):

💻 Local Development (12 failed)

astro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

express-stable-node (1 failed):

  • webhookWorkflow | wrun_41KZ54YEX40GYDFDRQSZBHWTXE

express-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

fastify-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

hono-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-canary-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-webpack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nitro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nuxt-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

sveltekit-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

vite-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0
📦 Local Production (13 failed)

astro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

express-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

fastify-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

hono-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-canary-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

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

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-webpack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nitro-stable-node (1 failed):

  • fetchWorkflow | wrun_41KZ553E7R0GRTVFYQF6HM8PNB

nitro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nuxt-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

sveltekit-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

vite-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0
🐘 Local Postgres (12 failed)

astro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

express-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

fastify-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

hono-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-canary-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-turbopack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

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

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nextjs-webpack-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nitro-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

nuxt-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

sveltekit-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

vite-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

🪟 Windows (1 failed)

nextjs-turbopack-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

📋 Other (9 failed)

e2e-local-dev-nest-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-local-dev-tanstack-start-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-local-postgres-nest-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-local-postgres-tanstack-start-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-local-prod-nest-stable-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-local-prod-tanstack-start-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-vercel-prod-nest-quickjs (1 failed):

  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

e2e-vercel-prod-tanstack-start-quickjs (2 failed):

  • sleepWinsRaceWorkflow | wrun_41KZ54ZYB40GSPFP2N0W0SYZ12
  • nullByteWorkflow | wrun_41KZ5504SD0GPFWXK85GTF8CS0

E2E Test Summary

Summary
Passed Failed Skipped Total
❌ ▲ Vercel Production 2917 15 478 3410
❌ 💻 Local Development 3118 12 435 3565
❌ 📦 Local Production 3253 13 454 3720
❌ 🐘 Local Postgres 3254 12 454 3720
❌ 🪟 Windows 309 1 0 310
❌ 📋 Other 2047 9 424 2480
✅ vercel-multi-region 27 0 0 27
Total 14925 62 2245 17232
Details by Category

❌ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 127 0 28
❌ astro-quickjs 126 1 28
✅ example-node 127 0 28
❌ example-quickjs 125 2 28
✅ express-node 127 0 28
❌ express-quickjs 125 2 28
✅ fastify-node 127 0 28
❌ fastify-quickjs 125 2 28
✅ hono-node 127 0 28
❌ hono-quickjs 126 1 28
✅ nextjs-turbopack-node 152 0 3
❌ nextjs-turbopack-quickjs 151 1 3
✅ nextjs-webpack-node 152 0 3
❌ nextjs-webpack-quickjs 151 1 3
✅ nitro-node 127 0 28
❌ nitro-quickjs 126 1 28
✅ nuxt-node 127 0 28
❌ nuxt-quickjs 125 2 28
✅ sveltekit-node 146 0 9
❌ sveltekit-quickjs 145 1 9
✅ vite-node 127 0 28
❌ vite-quickjs 126 1 28

❌ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 129 0 26
❌ astro-stable-quickjs 128 1 26
❌ express-stable-node 128 1 26
❌ express-stable-quickjs 128 1 26
✅ fastify-stable-node 129 0 26
❌ fastify-stable-quickjs 128 1 26
✅ hono-stable-node 129 0 26
❌ hono-stable-quickjs 128 1 26
✅ nextjs-turbopack-canary-node 136 0 19
❌ nextjs-turbopack-canary-quickjs 135 1 19
✅ nextjs-turbopack-stable-node 155 0 0
❌ nextjs-turbopack-stable-quickjs 154 1 0
✅ nextjs-webpack-canary-node 136 0 19
✅ nextjs-webpack-stable-node 155 0 0
❌ nextjs-webpack-stable-quickjs 154 1 0
✅ nitro-stable-node 129 0 26
❌ nitro-stable-quickjs 128 1 26
✅ nuxt-stable-node 129 0 26
❌ nuxt-stable-quickjs 128 1 26
✅ sveltekit-stable-node 148 0 7
❌ sveltekit-stable-quickjs 147 1 7
✅ vite-stable-node 129 0 26
❌ vite-stable-quickjs 128 1 26

❌ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 129 0 26
❌ astro-stable-quickjs 128 1 26
✅ express-stable-node 129 0 26
❌ express-stable-quickjs 128 1 26
✅ fastify-stable-node 129 0 26
❌ fastify-stable-quickjs 128 1 26
✅ hono-stable-node 129 0 26
❌ hono-stable-quickjs 128 1 26
✅ nextjs-turbopack-canary-node 136 0 19
❌ nextjs-turbopack-canary-quickjs 135 1 19
✅ nextjs-turbopack-stable-node 155 0 0
❌ nextjs-turbopack-stable-quickjs 154 1 0
✅ nextjs-webpack-canary-node 136 0 19
❌ nextjs-webpack-canary-quickjs 135 1 19
✅ nextjs-webpack-stable-node 155 0 0
❌ nextjs-webpack-stable-quickjs 154 1 0
❌ nitro-stable-node 128 1 26
❌ nitro-stable-quickjs 128 1 26
✅ nuxt-stable-node 129 0 26
❌ nuxt-stable-quickjs 128 1 26
✅ sveltekit-stable-node 148 0 7
❌ sveltekit-stable-quickjs 147 1 7
✅ vite-stable-node 129 0 26
❌ vite-stable-quickjs 128 1 26

❌ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 129 0 26
❌ astro-stable-quickjs 128 1 26
✅ express-stable-node 129 0 26
❌ express-stable-quickjs 128 1 26
✅ fastify-stable-node 129 0 26
❌ fastify-stable-quickjs 128 1 26
✅ hono-stable-node 129 0 26
❌ hono-stable-quickjs 128 1 26
✅ nextjs-turbopack-canary-node 136 0 19
❌ nextjs-turbopack-canary-quickjs 135 1 19
✅ nextjs-turbopack-stable-node 155 0 0
❌ nextjs-turbopack-stable-quickjs 154 1 0
✅ nextjs-webpack-canary-node 136 0 19
❌ nextjs-webpack-canary-quickjs 135 1 19
✅ nextjs-webpack-stable-node 155 0 0
❌ nextjs-webpack-stable-quickjs 154 1 0
✅ nitro-stable-node 129 0 26
❌ nitro-stable-quickjs 128 1 26
✅ nuxt-stable-node 129 0 26
❌ nuxt-stable-quickjs 128 1 26
✅ sveltekit-stable-node 148 0 7
❌ sveltekit-stable-quickjs 147 1 7
✅ vite-stable-node 129 0 26
❌ vite-stable-quickjs 128 1 26

❌ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 155 0 0
❌ nextjs-turbopack-quickjs 154 1 0

❌ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable-node 129 0 26
❌ e2e-local-dev-nest-stable-quickjs 128 1 26
✅ e2e-local-dev-tanstack-start-node 129 0 26
❌ e2e-local-dev-tanstack-start-quickjs 128 1 26
✅ e2e-local-postgres-nest-stable-node 129 0 26
❌ e2e-local-postgres-nest-stable-quickjs 128 1 26
✅ e2e-local-postgres-tanstack-start-node 129 0 26
❌ e2e-local-postgres-tanstack-start-quickjs 128 1 26
✅ e2e-local-prod-nest-stable-node 129 0 26
❌ e2e-local-prod-nest-stable-quickjs 128 1 26
✅ e2e-local-prod-tanstack-start-node 129 0 26
❌ e2e-local-prod-tanstack-start-quickjs 128 1 26
✅ e2e-vercel-prod-nest-node 127 0 28
❌ e2e-vercel-prod-nest-quickjs 126 1 28
✅ e2e-vercel-prod-tanstack-start-node 127 0 28
❌ e2e-vercel-prod-tanstack-start-quickjs 125 2 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@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 4, 2026 1:07am
example-nextjs-workflow-webpack Ready Ready Preview Aug 4, 2026 1:07am
example-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-astro-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-express-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-fastify-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-hono-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-nestjs-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-nitro-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-nuxt-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-sveltekit-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-tanstack-start-workflow Ready Ready Preview Aug 4, 2026 1:07am
workbench-vite-workflow Ready Ready Preview Aug 4, 2026 1:07am
workflow-docs Ready Ready Preview, v0 Aug 4, 2026 1:07am
workflow-swc-playground Ready Ready Preview Aug 4, 2026 1:07am
workflow-tarballs Ready Ready Preview Aug 4, 2026 1:07am
workflow-web Ready Ready Preview Aug 4, 2026 1:07am

@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/​quickjs-wasi@​3.3.07810010096100

View full report

@TooTallNate
TooTallNate requested a review from a team as a code owner July 31, 2026 22:59
(Re-applied onto the review-fixed base; original commits da27230 +
9814ed9 squashed.)

Replace the in-VM serde bundle with a host-side codec
(runtime/quickjs-serde.ts) built on quickjs-wasi 3.3's introspection
primitives and devalue 5.9's pluggable stringify/parse operations —
mirroring the node:vm engine's architecture.

Review fixes incorporated:
- reducer/reviver key sets are pinned against codec-devalue-vm's
  workflow mode by exhaustiveness tests (exact order for reducers —
  first match wins), so the handle-space codec can't silently drift
  from the shared value-space sets.
- the devalue entry in minimumReleaseAgeExclude is removed: the exact
  version is pinned via the workspace catalog + lockfile, so the
  cooldown waiver was unnecessary (verified with both frozen and
  regular installs).
- eval-string interpolation inherits the JSON.stringify(cid) hardening
  from the base branch.

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

Reviewed the incremental diff (+2823) and validated empirically against quickjs-wasi 3.3.0. Two silent data-corruption blockers on the guest→host string read — but the good news is a host-side fix exists, is small, and is validated (contrary to the "needs quickjs-wasi 3.4" first read): local branch pgp/quickjs-host-serde-fix (can push), 49 parity tests including 7 new string edge cases, full serde+runtime suites green, and nullByteWorkflow passes end-to-end under WORKFLOW_VM=quickjs. Details inline at primitiveOf.

Also verified/confirmed here:

  • Wire-format parity design is right and the reducer-key exhaustiveness pinning is good; side-effect-free classification holds (boot-sampled brands, captured intrinsics, descriptor reads; the spoofed-brand test passes). One wording nit: get does invoke own getters (parity with the hardened node codec), so "side-effect-free" is true of classification, not extraction.
  • PRNG determinism on the non-snapshot path is clean — the host-side monotonicFactory(() => rng()) shares the one seeded instance with VM Math.random, and the interleaved draw sequence matches the old in-VM factory.
  • devalue 5.9.0 and quickjs-wasi 3.3.0 are stock npm, unpatched, and past the 48 h minimumReleaseAge — installs are green; no dependency risk found.
  • Merge order: git merge-tree vs quickjs-vm-threshold-snapshots reports no textual conflict, so #3048#3049 → this → rebase {#3250#3251} is mechanically safe and matches the PR body. The cost is all semantic — three silent breaks for the snapshot rebase: (1) __generateUlid is registered inline (the snapshot branch's own rule: host callbacks never inline — it won't be re-registered on restore); (2) the restore path's "serde survives in the heap" comment becomes false — createQuickJSSerde(vm) must run on restore; (3) the ULID monotonic factory's internal state is host-side now, so it's not captured by the snapshot and the rngDraws fast-forward lands on the wrong draw position. (The deleted vm-bundle-entry.ts documented the late-binding factory as the affordance for exactly this.)
  • Byte cache: never evicted (grows for the VM's lifetime); the suspend path passes ensurePendingByteCache(vm) but collectDrainOperations passes no cache, so the same correlationId:field can serialize twice on different paths; and globalThis.__rawFields isn't cleaned up in a finally, so a throw mid-dumpPendingOps leaves it observable to workflow code.
  • Handle lifecycle: serialize() has no disposal sweep after stringify — roughly one leaked handle per value node per call; shapeOf leaks the symbol-key descriptor handles; and the pointer-keyed identities map will produce false devalue ref-identity if the planned bulk-free arena ever reuses pointers (worth a comment now).
  • Stale references: .github/workflows/tests.yml still lists vm-serde-bundle.generated.ts in the upload-artifact paths (the comment above it says to keep it aligned with turbo.json), and three comments in quickjs-entrypoint.ts still reference globalThis[Symbol.for('workflow-serialize')].
  • Changeset: given the wire-format work and dependency bumps, minor fits better than patch (matters on a stable backport even though beta numbering ignores it).

Test-hygiene notes on the (otherwise well-built) parity suite: the side-effect test installs permanent prototype patches with no restore; dead code at the byte-compare helper; and the mid-suite serde re-creation papers over cross-test handle-state coupling rather than proving it absent.

if (handle.isBool) return handle.toBoolean();
if (handle.isNumber) return handle.toNumber();
if (handle.isBigInt) return guestBigInt(handle);
return handle.toString();

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.

Blocker (CI-proven, all 15 quickjs legs): handle.toString() routes through JS_ToCString, which is NUL-terminated — any guest string containing U+0000 arrives silently truncated. This is the funnel for essentially every string crossing the boundary: primitives here, property keys, symbol descriptions, RegExp source, Headers/URL values. Worse, NUL-bearing object keys are silently dropped — the enumeration APIs (keys()/getOwnPropertyKeys()/propertyIsEnumerable()) truncate the key, the descriptor re-lookup misses, and the property vanishes (devl[{}]). And a second, independent corruption class rides the same read: QuickJS stores WTF-8, so one lone surrogate arrives as three U+FFFD (the reference codec yields one).

A host-side fix exists and is validated (branch pgp/quickjs-host-serde-fix) — no quickjs-wasi release needed:

  • guestString(): fast-path toString(), validated against the guest string's own .length (a plain data property, no guest code; truncation strictly shortens and the 1→3 surrogate expansion also mismatches, so equal lengths prove exactness). On mismatch, re-read via boot-captured JSON.stringify — QuickJS implements well-formed stringify, so NULs escape as \u0000 and lone surrogates as \ud800, and host JSON.parse revives both byte-exactly (verified empirically for both classes).
  • shapeOf(): enumerate string keys inside the VM via boot-captured Object.keys (same set/order as the host-side iteration it replaces), read through guestString.
  • Verified against 3.3.0: newString (host→VM) and getOwnPropertyDescriptor(string) are length-safe — only the read direction and enumeration need this.

Parity cases added: NUL in value, NUL in object key+value, NUL in RegExp source, lone surrogate, astral pair. nullByteWorkflow e2e passes with the fix.

const shape = reduceErrorShape(value) as Record<string, unknown>;
// retryAfter is a guest Date (or string/number); normalize to an epoch
// timestamp exactly like the in-VM reducer.
let retryAfter = Date.now() + 1000;

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.

Non-deterministic value written into the event log: the Date.now() + 1000 fallback when retryAfter is absent means a replay produces different bytes than the original serialization. Everything else in the error family (cause chains, registry subclasses) round-trips correctly — this is the one spot.

return {
reducerKeys: Object.keys(reducers),
reviverKeys: Object.keys(revivers),
serialize(value: JSValueHandle): Uint8Array {

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.

No disposal sweep after stringify — reducers create handle leaves (e.g. via dup() in collect) that are never freed: roughly one leaked handle per value node, per serialize call, unbounded within a VM's lifetime. Not a crash, but with #3049's long-lived sessions it accumulates across the whole inline batch. Eager finally disposal (or the bulk-free arena flagged in quickjs-wasi#26) closes it.

* its bytes are computed once even though the op is re-collected on every
* suspension it stays pending through.
*/
const pendingByteCache = new WeakMap<QuickJS, Map<string, Uint8Array>>();

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.

Byte-cache notes: (1) never evicted — keys are correlationId:field, so a long run grows this monotonically for the VM's lifetime; (2) the suspend path passes the cache but collectDrainOperations doesn't, so one op can serialize twice on different paths — with any getter re-invocation that's two different byte sequences for what the log treats as one value; (3) identities in the serde is keyed on raw pointer and only cleared at dispose — safe today, but a future bulk-free arena reusing pointers would produce false devalue ref identity. Worth comments/guards now.

@pranaygp

pranaygp commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fix branch pushed: pgp/quickjs-host-serde-fixguestString() + in-VM key enumeration, covering NUL values/keys/RegExp sources and lone surrogates, with 7 new parity cases (49/49 green, nullByteWorkflow e2e passes). Signed.

Carries the #3049 merge (and through it main/#3048) into the host-serde
engine. The merge was textually clean but needed one semantic
adaptation: the live-feed terminal buffer added on the perf branch
(__terminalBuffer / __registerResolver) originally buffered raw bytes
and deserialized them in-guest via Symbol.for('workflow-deserialize') —
a global this branch retires along with the VM serde bundle. The buffer
now stores host-deserialized VM values instead: the no-resolver
branches of step_completed / step_failed run the same
serde.deserialize() path as their resolver branches and buffer the
resulting value ('resolve_value' / 'reject_value'), so draining at
promise construction only forwards it.

Validated: core 1932 passed; hook (26/26), promiseRace and fail e2e
green under WORKFLOW_VM=quickjs.
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