') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); QuickJS engine: host-side, side-effect-free serialization via handles by TooTallNate · Pull Request #3263 · vercel/workflow · GitHub
Skip to content

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

Merged
TooTallNate merged 27 commits into
mainfrom
quickjs-host-serde
Aug 6, 2026
Merged

QuickJS engine: host-side, side-effect-free serialization via handles#3263
TooTallNate merged 27 commits into
mainfrom
quickjs-host-serde

Conversation

@TooTallNate

@TooTallNateTooTallNate 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 quickjs-wasi 3.3.0's host-side introspection primitives (classId brand checks, identity, descriptor reads, vm.construct, ephemeral functions; see vercel-labs/quickjs-wasi#24/vercel-labs/quickjs-wasi#26) and devalue's pluggable stringify/parse operations (already on main via #3257's devalue 5.9 bump).

Performance

Counter-intuitively, host-side serde is faster across every payload shape — the boundary hops are cheap C calls, while the old approach ran the whole codec as interpreted JS inside an interpreted VM. Full-replay wall time (10-step payload-piping workflow, median of 7, details in this comment):

payloadin-VM bundlehost serdespeedup
small (29 B)6.9 ms3.8 ms1.8×
200-key object (7 KB)58.3 ms15.9 ms3.7×
5k-object array (370 KB)4214.9 ms1447.8 ms2.9×
512 KB string2494.9 ms23.8 ms105×
Maps/Sets/Dates (10 KB)129.0 ms63.3 ms2.0×

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.
  • NUL (U+0000) safety: JS_ToCString-backed extraction truncates at the first NUL and mangles NUL-bearing property keys. guestString() detects truncation against the handle's true guest length and recovers via in-VM JSON.stringify escaping; key enumeration verifies its fast path against a guest Object.keys count and re-extracts through length-aware key handles on mismatch; NUL-bearing keys route through handle-keyed get/set.
  • 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 shared between the suspension and terminal-drain paths (one byte sequence per op, even when getters are involved) and evicted as ops settle.
  • 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. RetryableError's retryAfter fallback reads the guest's deterministic replay clock, not the host wall clock. Existing runs replay byte-for-byte.
  • Handle lifecycle: serialize/deserialize passes sweep every intermediate handle created during the pass via vm.withScope (requires quickjs-wasi ≥ 3.3.1, whose borrowed-handle fix — fix: exempt host-callback this/argument handles from scopes and dispose() vercel-labs/quickjs-wasi#31, found by this PR — makes scopes safe around host callbacks).

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 parity suite byte-compares against it in both directions — 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, NUL-bearing strings/keys (including the truncate-collision enumeration shape), and patched-prototype/spoof resistance. Reducer/reviver key sets are pinned against codec-devalue-vm's so drift fails loudly.

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

  • 49 serde parity/safety tests (incl. the NUL suite); 24 quickjs-runtime tests green (fixtures built with the reference codec, so they validate parity end to end)
  • Full @workflow/core suite: 1961 passed | 3 expected fail
  • Local e2e (WORKFLOW_VM=quickjs, dev server): nullByteWorkflow ✓, hooks 27✓, sleep/step races 3✓

Follow-up candidates

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

changeset-botBot commented Jul 31, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e40673

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

This PR includes changesets to release 16 packages
NameType
@workflow/corePatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

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

CopilotAI 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-actionsBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
PassedFailedSkippedTotal
✅ ▲ Vercel Production293205003432
✅ 💻 Local Development329004543744
✅ 📦 Local Production329004543744
✅ 🐘 Local Postgres329004543744
✅ 🪟 Windows31200312
✅ 📋 Other206804282496
✅ vercel-multi-region270027
Total152090229017499
Details by Category

✅ ▲ Vercel Production

AppPassedFailedSkipped
✅ astro-node127029
✅ astro-quickjs127029
✅ example-node127029
✅ example-quickjs127029
✅ express-node127029
✅ express-quickjs127029
✅ fastify-node127029
✅ fastify-quickjs127029
✅ hono-node127029
✅ hono-quickjs127029
✅ nextjs-turbopack-node15204
✅ nextjs-turbopack-quickjs15204
✅ nextjs-webpack-node15204
✅ nextjs-webpack-quickjs15204
✅ nitro-node127029
✅ nitro-quickjs127029
✅ nuxt-node127029
✅ nuxt-quickjs127029
✅ sveltekit-node146010
✅ sveltekit-quickjs146010
✅ vite-node127029
✅ vite-quickjs127029

✅ 💻 Local Development

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 📦 Local Production

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🐘 Local Postgres

AppPassedFailedSkipped
✅ astro-stable-node130026
✅ astro-stable-quickjs130026
✅ express-stable-node130026
✅ express-stable-quickjs130026
✅ fastify-stable-node130026
✅ fastify-stable-quickjs130026
✅ hono-stable-node130026
✅ hono-stable-quickjs130026
✅ nextjs-turbopack-canary-node137019
✅ nextjs-turbopack-canary-quickjs137019
✅ nextjs-turbopack-stable-node15600
✅ nextjs-turbopack-stable-quickjs15600
✅ nextjs-webpack-canary-node137019
✅ nextjs-webpack-canary-quickjs137019
✅ nextjs-webpack-stable-node15600
✅ nextjs-webpack-stable-quickjs15600
✅ nitro-stable-node130026
✅ nitro-stable-quickjs130026
✅ nuxt-stable-node130026
✅ nuxt-stable-quickjs130026
✅ sveltekit-stable-node14907
✅ sveltekit-stable-quickjs14907
✅ vite-stable-node130026
✅ vite-stable-quickjs130026

✅ 🪟 Windows

AppPassedFailedSkipped
✅ nextjs-turbopack-node15600
✅ nextjs-turbopack-quickjs15600

✅ 📋 Other

AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable-node130026
✅ e2e-local-dev-nest-stable-quickjs130026
✅ e2e-local-dev-tanstack-start-node130026
✅ e2e-local-dev-tanstack-start-quickjs130026
✅ e2e-local-postgres-nest-stable-node130026
✅ e2e-local-postgres-nest-stable-quickjs130026
✅ e2e-local-postgres-tanstack-start-node130026
✅ e2e-local-postgres-tanstack-start-quickjs130026
✅ e2e-local-prod-nest-stable-node130026
✅ e2e-local-prod-nest-stable-quickjs130026
✅ e2e-local-prod-tanstack-start-node130026
✅ e2e-local-prod-tanstack-start-quickjs130026
✅ e2e-vercel-prod-nest-node127029
✅ e2e-vercel-prod-nest-quickjs127029
✅ e2e-vercel-prod-tanstack-start-node127029
✅ e2e-vercel-prod-tanstack-start-quickjs127029

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@vercel

vercelBot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@socket-security

socket-securityBot commented Jul 31, 2026

Copy link
Copy Markdown

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

DiffPackageSupply Chain
Security
VulnerabilityQualityMaintenanceLicense
Addednpm/​quickjs-wasi@​3.4.07810010096100

View full report

…ed (encp) hook payloads open
Main's sealed-box work (#3096) makes cross-deployment resumeHook() seal
hook payloads to the target run's published X25519 public key. The shared
start() path publishes that key regardless of engine, so QuickJS runs
receive sealed payloads too — but the QuickJS entrypoint resolved only the
bare symmetric key via importKey(), which cannot open encp envelopes. The
first sealed hook payload wedged the run right after hook_received, timing
out every hook/webhook e2e on Vercel prod (node:vm legs were fine — the
node engine resolves the full capability via memoizeEncryptionKey).
Resolve deriveRunPayloadKeys() in the entrypoint instead and widen the
runtime's key types from CryptoKey to DecryptionKey. Writes stay symmetric
(encrypt() with RunPayloadKeys takes the encr path). Regression test seals
a payload exactly as resumeHook does and round-trips it through the VM.
…import, VM-leak guard, telemetry namespace, eval-string escaping
- Deterministic crypto.getRandomValues/randomUUID in the VM bootstrap,
drawing from the seeded Math.random (identical sequences to the node
engine's vm/index.ts implementations); all crypto.subtle methods throw
with step-function guidance. process.env exposed as a frozen copy,
matching node.
- Intl: throwing constructors (no ICU in QuickJS), and toLocale*-family
methods (incl. localeCompare) throw when given an explicit locale so
cross-engine divergence is loud instead of silently writing different
values into the event log. No-argument forms keep working.
- runtime.ts lazy-imports the QuickJS entrypoint at dispatch, keeping the
~1.3MB embedded WASM assets out of node-engine deployments.
- runQuickJSWorkflow wraps the per-run phase so an exceptional exit
disposes the VM instead of leaking it in a reused compute instance;
corrected the misleading fail-loud comment (run_failed, not retry);
warn when the event drain loop exhausts its iteration bound.
- Telemetry attributes renamed quickjs.* → workflow.vm.* to stay in the
file's workflow.* namespace.
- Eval-string correlation-id interpolation uses JSON.stringify instead of
quote-only escaping.
- common-vm.test.ts pins the reducer/reviver superset invariant against
common.ts so the duplicated sets can't silently drift.
- Docs enumerate the remaining global-surface differences (subtle.digest,
Intl, WebAssembly, Atomics); quickjs-entrypoint documents the known
precondition-guard gap.
…tion + resumeId dedup)
#1834 made resumeHook() fall back to enqueueing the run with a hookInput
payload when the direct hook_received write fails transiently, with the
runtime materializing the missing event on delivery. Only the node:vm
path implemented it — the QuickJS dispatch returned before the node
block, so the resilient payload was silently dropped and the new e2e
timed out on every quickjs leg.
- runtime.ts threads hookInput into runWorkflowWithQuickJS; the
entrypoint materializes the missing hook_received after loading the
event log (resumeId-keyed dedup, occurredAt from the resumeId ULID,
local eventData substitution for lazy/ref responses, EntityConflict /
HookNotFound handling) — mirroring the node block.
- processEvents drops duplicate hook_received rows sharing a resumeId
(first-in-log wins), matching the node engine's EventsConsumer dedup;
the seen-set lives in the VM heap so it is deterministic per replay.
Verified against the dev server with WORKFLOW_VM=quickjs: the resilient
resume e2e passes and the materialization is observable in the logs; all
27 hook e2e tests green.
…loop event ceiling
- Inline steps now claim via a lazy step_started carrying the input
(step_created deferred, atomic create-claim in the world), with
ownerMessageId stamped and authoritativeAttempt=1 — a concurrent
invocation racing on the same fresh step loses with
EntityConflictError and skips instead of both bare-starting the step
and double-running the body. This also removes the stepsCreatedByUs
set, whose 'created by us' invariant didn't survive the swallowed
create-race conflict; redelivery backstops now key on hasCreatedEvent.
- dispatchPendingOps' createdAttributeEvent/createdGetConflictHook
signals are consumed again: when the loop exits suspended without ever
reading back a self-written attr_set / getConflict hook_created
(eventually-consistent listing lag), the entrypoint requeues
immediately instead of parking the run awaiting_external with its
unblocking event already written.
- The server-supplied event ceiling is re-checked at the top of every
continuation-loop turn (seenEventIds.size), so a single invocation
fanning out inline can no longer grow the log arbitrarily past the
operator's limit. The quickjs dispatch in runtime.ts converts
MaxEventsExceededError into run_failed / MAX_EVENTS_EXCEEDED — the
guard's throw previously nacked forever, parking runaway runs in
'running'.
- Documented the deliberate decision that the platform function timeout
is the only bound on inline chaining (budget parked per batch),
matching the node engine.
(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.

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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fixed in f88165c. Confirmed empirically: toString() on "ab\u0000cd" returns "ab", and NUL-bearing keys truncate in the enumeration APIs (dropping when the truncated name fails the enumerability probe, colliding when a sibling shares it). The fix funnels every guest→host string through guestString() — truncation is detected by comparing against handle.length (the true guest length, so NUL-free strings pay only a property read) and recovered via in-VM JSON.stringify escaping, whose output is NUL-free by construction. Key enumeration verifies the fast host-string path against a guest Object.keys count + duplicate check and re-extracts through length-aware key handles on mismatch; get/hasOwn route NUL-bearing keys through handle-keyed access (vm.newString is length-aware, verified). The parse/build side was already safe (define() goes through guest key handles). Regression tests cover values (leading/trailing/middle/multi NUL), byte parity with the reference codec, and both enumeration corruption shapes; nullByteWorkflow passes under WORKFLOW_VM=quickjs locally.

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fixed in f88165c — the fallback now reads the GUEST clock (a captured Date.now, which sits on the deterministic replay clock at the WASI layer) instead of the host wall clock. The in-VM reducer's Date.now() was replay-stable by construction; the host port silently swapped it for wall time — this restores the original semantics rather than changing the wire shape (node's reducer always emits a number here, so omitting the field would have been a cross-engine format change).

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fixed in f88165c — serialize and deserialize now sweep every intermediate handle their pass creates (call/invoke results, descriptor reads, dups, parse-op constructions; deserialize escapes the root). One finding worth flagging: the obvious implementation — vm.withScope — is UNSAFE here. The library scope registers every handle constructed while active, including the ones handleHostCall wraps around C-owned argv pointers when a host callback runs (our Map/Set/Headers forEach visitors, mid-pass). Disposing those double-frees guest values; observed as WASM memory access out of bounds in the parity suite. The sweep therefore uses module-owned tracking fed only by this module's creation funnels, which is safe by construction. Filed as a follow-up note on the PR for an upstream fix (suspend the active scope in the trampoline, as getPromiseThen already does). identities is also cleared per pass now — with handles being freed, pointer reuse across passes could otherwise alias stale identity entries (your point 3 on the byte-cache thread).

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Follow-up: the library-side fix is now up as vercel-labs/quickjs-wasi#31 — host-callback this/argument handles become 'borrowed' (never scope-tracked, dispose() no-op, dup() for retention), with regression tests covering the exact forEach-visitor corruption shape found here. Once that ships, vm.withScope becomes a valid alternative to this PR's module-owned tracking; the tracking approach stays correct either way (it's a strict subset — only handles this module creates), so no change needed here.

* 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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fixed in f88165c — all three points: (1) eviction — the collection pass now surfaces settled ops (created + resolver-less + no abort in flight, which neither the suspension nor the drain filter can ever match again) in the same guest sweep, and their cache entries are dropped, bounding the cache by the live pending set; (2) collectDrainOperations now shares the per-VM cache, so an op serialized on the suspension path reuses those exact bytes at terminal drain instead of risking a second, getter-divergent byte sequence; (3) identities is now cleared per serialize pass (it only needs intra-pass stability), which makes it immune to pointer reuse — necessary anyway now that the pass-disposal sweep actually frees handles (see the :1967 thread).

@pranaygp

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.

…hreads
Merge resolution — main's #3048 finals carried into the inline-loop
architecture:
- namespace + run-origin nextTraceCarrier threaded through
runWorkflowWithQuickJS into every publish (step handoffs, hook_conflict
requeue, wait continuations, immediate requeues)
- suspended-exit requeues converted to FRESH messages (never
{ timeoutSeconds } visibility-redelivery of the current message — the
hookInput redelivery trap fixed on #3048); exit wait sweep enqueues the
continuation for the soonest unscheduled wait directly
- entrypoint-side hookInput materialization dropped in favor of main's
engine-agnostic prologue re-ensure in runtime.ts (with #3230's
(runId, resumeId) claim protocol); dispatch stays inside the replay
loop's try so engine failures classify into run_failed
- interrupt handler keeps the perf branch's per-burst mutable budget,
with main's configurable getReplayTimeoutMs() as the ceiling
Review fixes (PR #3049 threads):
- CRITICAL overflow wedge: overflow steps are handed to the queue in the
same turn their step_created is written, BEFORE the event feed — the
feed always observes those writes and continued the loop, so the old
handoff was unreachable on the only turn that classified the steps as
fresh (the cause of promiseRaceStressTestWorkflow hanging in the
quickjs CI legs)
- backstop gating: the deliveryAttempt > 1 gate (common case on worlds
that advance attempts on routine redeliveries) is replaced with the
node engine's ownership decision table — lease-active steps owned by
another message arm a DELAYED backstop for the lease remainder under
an epoch-scoped key; owner redeliveries and expired/unstamped steps
dispatch immediately under the bare-correlationId key. Ownership is
derived host-side from observed step_started/step_retrying events
- ack-without-requeue: inline step terminals the feed has not surfaced
raise the requeue signal, so the loop never acks with durably written
terminals and nothing scheduled to consume them
- idempotency keys bucketed by purpose (dispatch / backstop:<epoch> /
retry:<n>) so worlds that retire used keys cannot swallow a later
publish for the same step
- live-feed terminal buffering: step/wait/attr terminals arriving before
this VM constructs the corresponding resolver are buffered
(__terminalBuffer, mirroring __hookPayloadBuffer) and settle the
promise at construction — the single-scan continuation path previously
dropped them and the await never settled
Validated: core 1888 passed, full e2e 136/136 under WORKFLOW_VM=quickjs
(nextjs-turbopack dev, world-local).
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.
@TooTallNate

Copy link
Copy Markdown
MemberAuthor

Serde performance: host-side (this PR) vs in-VM bundle (base)

Benchmarked the question of whether host-side serde pays for its repeated WASM-boundary hops vs running devalue entirely inside the VM. It's the opposite: host-side serde is faster across every payload shape tested — 1.8× to 105×.

Method: identical script on both branches' built dists — a 10-step workflow piping a payload step→step through runQuickJSWorkflow full replay (fresh VM per invocation, WASM module cache warm, median of 7 after 2 warmups, same machine back-to-back). final = full log to completion (deserialize input + 10 step results into the VM, serialize the return out). mid = half log to suspension (additionally serializes the next step's input out).

payloadserializedin-VM finalhost finalspeedupin-VM midhost midspeedup
small29 B6.9 ms3.8 ms1.8×5.8 ms3.4 ms1.7×
wide200 (200-key obj)7 KB58.3 ms15.9 ms3.7×33.6 ms10.3 ms3.3×
array5k (5k objs)370 KB4214.9 ms1447.8 ms2.9×2301.4 ms828.6 ms2.8×
string512k524 KB2494.9 ms23.8 ms105×1362.9 ms15.1 ms90×
rich (Maps/Sets/Dates)10 KB129.0 ms63.3 ms2.0×71.5 ms35.7 ms2.0×

Why the intuition inverts: the boundary hops are cheap C calls per value node, but the in-VM approach runs the entire devalue codec as interpreted JS inside an interpreted VM — QuickJS-on-WASM executes JS roughly two orders of magnitude slower than V8's JIT, and every payload byte additionally round-trips through VM-heap Uint8Arrays and in-VM UTF-8 string handling. Host-side serde does the codec work at native V8 speed and only crosses the boundary to construct/read the final value graph. The 512 KB string is the purest illustration: one newString handle op vs QuickJS chewing through a half-megabyte devalue parse.

Two secondary effects worth noting:

  • the small payload's 1.8× shows the fixed win from not evaluating the VM serde bundle at every VM boot;
  • unmeasured but real: the codec's temporaries no longer live in the 256 MB WASM heap, reducing VM memory pressure for large payloads.

So this PR is a performance improvement in addition to its correctness/hardening goals. Bench script available on request.

Resolution: host-serde adaptations kept for all serde-threading and
terminal-buffer conflicts (value-kind buffering via host deserialize);
main's sleepWinsRace wait-continuation fix taken in the entrypoint
scheduling sweep.
… pass-scoped handle disposal, byte-cache lifecycle
- NUL (U+0000) safety across the WASM boundary: handle.toString() routes
through JS_ToCString and silently truncates at the first NUL, and the
C-string key APIs mangle NUL-bearing property keys (drop or collide).
guestString() detects truncation by comparing against the handle's
true guest length and recovers via in-VM JSON.stringify escaping;
shapeOf verifies its fast host-string key list against a guest
Object.keys count (+ duplicate check) and re-extracts through key
handles on mismatch; get/hasOwn route NUL-bearing keys through
length-aware guest string handles. All string funnels (primitives,
symbol descriptions, error fields via chained/own reads, Headers
entries, RegExp source/flags, URL href) go through guestString.
Regression-tested down to the truncate-vs-collide enumeration shapes;
fixes nullByteWorkflow on the quickjs e2e legs.
- RetryableError's absent/invalid retryAfter fallback now reads the
GUEST clock (the deterministic replay clock at the WASI layer) via a
captured Date.now instead of the host wall clock — the in-VM reducer
was replay-stable by construction and the host port silently lost
that.
- Pass-scoped handle disposal: serialize/deserialize sweep every
intermediate handle their pass creates (call/invoke results,
descriptor reads, dups, parse-op constructions), closing the
~one-leaked-handle-per-value-node growth across long-lived inline
sessions. Implemented with module-owned tracking rather than
vm.withScope: the library scope also captures the handles the
host-callback trampoline wraps around C-owned argv pointers, and
disposing those (Map/Set/Headers forEach visitors run mid-pass)
double-frees guest values — observed as WASM memory corruption.
identities is cleared per pass so freed-pointer reuse cannot alias
entries across passes.
- Byte-cache lifecycle: terminal drain now shares the per-VM cache with
the suspension path (re-serializing an op at drain could re-invoke
getters and produce different bytes for what the log treats as one
value), and entries for settled ops — which neither collection filter
can match again — are evicted, bounding the cache by the live pending
set.
@github-actions

github-actionsBot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 5e40673 · Thu, 06 Aug 2026 07:32:55 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1291 (+40%) 🔻1375 🔴 (+12%)1403 🔴 (+5.4%)1449 🔴 (-12%)30
TTFSstream372 (+43%) 🔻1359 🔴 (+14%)1376 🔴 (+13%)1389 🔴 (+8.7%)30
TTFShook + stream1506 (+266%) 🔻1632 🔴 (+7.2%)1667 🔴 (+5.6%)1818 🔴 (-11%)30
STSO1020 steps (inline)105 (-0.9%)138 (-24%) 💚153 (-28%) 💚221 (-34%) 💚1018
STSO1020 steps (queue-hop)31833183318331831
WO1020 steps141234 (-18%) 💚141234 (-18%) 💚141234 (-18%) 💚141234 (-18%) 💚1
SLstream latency114 (+12%)165 🔴 (-10%)177 🔴 (-43%) 💚3999 🔴 (+609%) 🔻30
SOstream overhead (text)127 (-11%)191 (-27%) 💚224 (-27%) 💚351 (-26%) 💚30
SOstream overhead (structured)125 (-23%) 💚167 (-57%) 💚190 (-64%) 💚337 (-62%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 170730ms → this run 136530ms (Δ -34200ms, -20%)

100-150 ms ██████████░░░░░░░░░░░░░┃ main 385 this 892 +507
150-200 ms ██┃██████████ main 485 this 103 -382
200-250 ms ┃██ main 109 this 16 -93
250-300 ms ┃ main 27 this 4 -23
300-350 ms ┃ main 5 this 1 -4
350-400 ms ┃ main 5 this 2 -3
400-450 ms ┃ main 1 this 0 -1
450-500 ms ┃ main 2 this 0 -2

1020 steps (queue-hop)

Cumulative STSO time: 3183ms over 1 samples

No main baseline with raw samples yet — showing this run's distribution on its own; the diff appears once a run on main has recorded them.

3000-3500 ms ████████████████████████ steps 1
📜 Previous results (3)

d101fa2

Wed, 05 Aug 2026 22:30:33 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep213 (-72%) 💚1414 🔴 (+45%) 🔻1462 🔴 (+38%) 🔻1592 🔴 (-5.1%)30
TTFSstream251 (-72%) 💚1359 🔴 (+40%) 🔻1420 🔴 (+43%) 🔻1493 🔴 (+15%) 🔻30
TTFShook + stream393 (-55%) 💚1679 🔴 (+36%) 🔻1733 🔴 (+38%) 🔻1837 🔴 (+23%) 🔻30
STSO1020 steps (inline)94 (+4.4%)150 (+15%)175 (+12%)338 (+37%) 🔻1018
STSO1020 steps (queue-hop)37743774377437741
WO1020 steps153008 (+18%) 🔻153008 (+18%) 🔻153008 (+18%) 🔻153008 (+18%) 🔻1
SLstream latency109 (+35%) 🔻194 🔴 (+56%) 🔻227 🔴 (+51%) 🔻668 🔴 (+63%) 🔻30
SOstream overhead (text)140 (+37%) 🔻211 (+34%) 🔻241 (+32%) 🔻867 (+336%) 🔻30
SOstream overhead (structured)130 (+26%) 🔻267 🔴 (+63%) 🔻393 (+121%) 🔻1094 🔴 (+246%) 🔻30

ef693e3

Tue, 04 Aug 2026 22:42:22 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1065 (+20%) 🔻1473 🔴 (+42%) 🔻1570 🔴 (+18%) 🔻1811 🔴 (+22%) 🔻30
TTFSstream1309 (+34%) 🔻1382 🔴 (+34%) 🔻1389 🔴 (+33%) 🔻1525 🔴 (+31%) 🔻30
TTFShook + stream609 (+45%) 🔻1812 🔴 (+39%) 🔻1915 🔴 (+25%) 🔻2380 🔴 (+39%) 🔻30
STSO1020 steps (inline)131 (+54%) 🔻158 (+13%)175 (+5.4%)285 (-34%) 💚1018
STSO1020 steps (queue-hop)3089 (-35%) 💚3089 (-35%) 💚3089 (-35%) 💚3089 (-35%) 💚1
WO1020 steps163719 (+9.4%)163719 (+9.4%)163719 (+9.4%)163719 (+9.4%)1
SLstream latency146 (+78%) 🔻189 🔴 (+75%) 🔻205 🔴 (+74%) 🔻393 🔴 (+133%) 🔻30
SOstream overhead (text)164 (+58%) 🔻237 (+40%) 🔻245 (+37%) 🔻453 (+26%) 🔻30
SOstream overhead (structured)156 (+50%) 🔻222 (+26%) 🔻239 (+2.6%)292 (-43%) 💚30

f88165c

Tue, 04 Aug 2026 21:46:52 GMT · run logs

vercel / nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep1248 (+483%) 🔻1319 🔴 (+10%)1352 🔴 (+9.7%)1675 🔴 (+2.4%)30
TTFSstream1249 (+408%) 🔻1327 🔴 (+24%) 🔻1346 🔴 (+23%) 🔻1587 🔴 (+39%) 🔻30
TTFShook + stream1395 (+267%) 🔻1680 🔴 (+27%) 🔻1762 🔴 (+28%) 🔻2151 🔴 (+47%) 🔻30
STSO1020 steps (inline)85 (-15%)130 (-19%) 💚152 (-22%) 💚279 (-36%) 💚1018
STSO1020 steps (queue-hop)3030 (+2.1%)3030 (+2.1%)3030 (+2.1%)3030 (+2.1%)1
WO1020 steps142413 (-16%) 💚142413 (-16%) 💚142413 (-16%) 💚142413 (-16%) 💚1
SLstream latency88 (-9.3%)151 🔴 (+1.3%)160 🔴 (-28%) 💚196 🔴 (-74%) 💚30
SOstream overhead (text)107 (-11%)157 (-40%) 💚179 (-69%) 💚334 (-59%) 💚30
SOstream overhead (structured)107 (-8.5%)175 (-44%) 💚184 (-68%) 💚224 (-74%) 💚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.

… accounting, loud unregistered-callback failures
3.3.1 ships the three fixes this branch surfaced upstream:
- Borrowed host-callback handles (vercel-labs/quickjs-wasi#31): the
trampoline's this/argv handles are scope-exempt, making vm.withScope
safe around host callbacks. The serde's module-owned pass-disposal
apparatus (passDisposal/track/runWithPassDisposal and ~18 track()
wraps) is replaced by withScope in serialize/deserialize — simpler,
and strictly more complete: every handle constructed during the pass
is swept, not just the ones our creation funnels saw. Bench parity
confirmed (within ~10% on the 50k-node extreme case, unchanged
elsewhere; still 2.6-100x over the in-VM codec).
- Real memoryLimit accounting (vercel-labs/quickjs-wasi#33): the
engine's 256 MB VM ceiling now actually bounds retained guest
allocations (usable-size was 0 on wasm32-wasi before, so the limit
never accumulated).
- Unregistered host callbacks throw (vercel-labs/quickjs-wasi#34):
guest calls into missing callbacks fail loud instead of silently
returning undefined — protection this engine wants for
snapshot-restore re-registration bugs.
Also merges origin/main (undici 7.29.0).

@karthikscale3karthikscale3 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: two inline findings—one reproducible serialization correctness blocker and one portability concern.

*/
const guestString = (handle: JSValueHandle): string => {
const fast = handle.toString();
if (fast.length === handle.length) return fast;

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 length equality is not sufficient to prove that JS_ToCString was lossless because its two known corruptions can cancel each other out. I reproduced this with a focused parity test using the guest string "\ud800\u0000a": the lone surrogate expands to three U+FFFD characters while the NUL truncates the two-code-unit suffix, so both fast.length and handle.length are 3 and this returns the corrupt fast value. The durable bytes were devl["���"]; the reference codec produced devl["�\u0000a"]. All existing 49 serde tests passed while this added case failed. Please make the fallback trigger on length mismatch or a U+FFFD in the fast result (legitimate replacement characters can safely take the slow path), and add mixed lone-surrogate+NUL cases for values and keys.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Excellent catch — fixed in d101fa2, and your reproduction understated it slightly: probing this build showed a BARE lone surrogate corrupts with matching lengths (1:1 U+FFFD replacement, no NUL needed), and the JSON.stringify slow path was itself lossy for lone surrogates — QuickJS passes them through raw, and the C-string extraction of its output corrupts them. So the fix is two-part: (1) the fast value is accepted only on length match AND no U+FFFD (legit-U+FFFD strings take the loss-free slow path, per your suggestion); (2) the slow path now escapes INSIDE the VM to printable ASCII via a new captured escapeString intrinsic (per-code-unit \uXXXX on WTF-16, so lone surrogates survive) and JSON-parses host-side. The key-enumeration guard gains the same U+FFFD scan (lone-surrogate keys corrupt with count and uniqueness intact), and get/hasOwn route handle-keyed for keys carrying a NUL or UNPAIRED surrogate (vm.newString verified WTF-16-preserving; paired surrogates encode fine). Tests added: your exact canceling case, bare surrogates, legit-U+FFFD passthrough, reference-codec byte parity, and surrogate/mixed keys.


function bytesToBase64(bytes: Uint8Array): string {
if (bytes.length === 0) return '.';
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).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.

[P2; blocker if WASM-only/non-Node hosts are in scope] This adds an unconditional Node Buffer dependency to every binary serde path. In Cloudflare Workers and similar runtimes, typed-array/ArrayBuffer serialization now throws unless the bundler injects a Buffer polyfill. Main already has one Buffer.isBuffer() diagnostic on QuickJS replay, so there is existing portability debt, but this PR expands it into the codec itself and conflicts with the QuickJS engine's WASM-only portability goal. Please use the portable strategy used elsewhere: native Uint8Array base64 methods when available, Buffer only when Node is detected, and btoa/atob fallback.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Fixed in d101fa2 — the base64 helpers are now feature-detected exactly as you suggested: Uint8Array.fromBase64/toBase64 when available, Buffer when present, btoa/atob loop otherwise. No unconditional Node dependency remains in the codec (the one pre-existing Buffer.isBuffer diagnostic on the replay path is debug-log-only and untouched here). The wire-parity suite pins that all three paths produce identical bytes on this runtime's active path.

…ase64
P1 — the guestString length check was insufficient: JS_ToCString has
TWO corruptions (NUL truncation, lone-surrogate -> U+FFFD replacement)
and they can cancel — the replacement expansion offsets the truncation
so the extracted length matches the true guest length. A bare lone
surrogate can also replace 1:1 with no length change at all. Worse,
the JSON.stringify slow path was itself lossy for lone surrogates:
QuickJS passes them through raw, and the C-string extraction of ITS
output corrupts them.
- guestString accepts the fast value only when length matches AND it
contains no U+FFFD (legitimate U+FFFD strings take the loss-free slow
path); the slow path now escapes INSIDE the VM to printable ASCII via
a new captured escapeString intrinsic (WTF-16-safe per-code-unit
\uXXXX escaping), then JSON-parses host-side.
- shapeOf's fast-key acceptance adds a U+FFFD scan alongside the
count/duplicate checks (lone-surrogate keys corrupt with count and
uniqueness intact).
- get()/hasOwn() route keys through guest string handles when they
carry a NUL or an UNPAIRED surrogate (paired surrogates - emoji keys
- encode fine through the C-string APIs; vm.newString is verified
WTF-16-preserving for the handle path).
- Tests: the reviewer's exact length-canceling case, bare lone
surrogates, legit-U+FFFD passthrough, byte parity with the reference
codec, and lone-surrogate/mixed keys.
P2 — the codec's base64 helpers no longer carry an unconditional Node
Buffer dependency: feature-detected Uint8Array.fromBase64/toBase64
when available, Buffer when present, btoa/atob loop otherwise —
keeping WASM-only/non-Node hosts (Cloudflare Workers) viable.
3.4.0 ships lossless string transport (vercel-labs/quickjs-wasi#35 —
found by this PR's review cycle), so the SDK-side detection and escape
machinery is deleted wholesale:
- guestString (length + U+FFFD detection, in-VM escape fallback) — plain
toString() is lossless now
- the escapeString / hasOwnCall / jsonStringify / objectKeys captured
intrinsics
- keyNeedsHandleLookup and the handle-keyed get/hasOwn routing — the
library routes inexpressible keys itself
- shapeOf's guest-key verification pass (count/duplicate/U+FFFD scans) —
enumeration is lossless
Net ~130 lines and four captured intrinsics removed; the serde now uses
the plain quickjs-wasi surface everywhere.
Test honesty fix that 3.4.0 forced: the earlier lone-surrogate
round-trip tests passed only via mutual corruption — the pre-3.4.0
lossy host→guest transport corrupted the guest comparison literals
identically to the wire. With an honest transport they exposed that the
WIRE itself (devalue emits lone surrogates raw; the wire is UTF-8)
degrades lone surrogates to U+FFFD — in the node engine's reference
codec exactly as here, verified. Bug-compatible parity is the
load-bearing property (event logs replay across engines), so those
tests now assert byte parity with the reference codec plus
guest-observed equality with the reference codec's own round trip; NULs
are devalue-escaped and asserted to survive exactly. Wire-level
surrogate preservation is a product-wide devalue/UTF-8 question,
tracked separately from this engine.

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

ai review: Re-checked the latest head, lossless QuickJS string transport, portable base64 fallbacks, regression coverage, and current CI. No remaining code-review blockers found.

Sign up for freeto 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.

5 participants

@TooTallNate@pranaygp@VaguelySerious@karthikscale3