Skip to content

perf(core): retain workflow VM across inline steps#2990

Open
NathanColosimo wants to merge 23 commits into
mainfrom
nathanc/workflow-replay-perf-43e0a8
Open

perf(core): retain workflow VM across inline steps#2990
NathanColosimo wants to merge 23 commits into
mainfrom
nathanc/workflow-replay-perf-43e0a8

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
  • retain one workflow VM, event consumer, async stack, and hydrated state across inline step suspensions within a single queue invocation
  • append only newly durable events when the inline loop resumes the workflow
  • fall back permanently to ordinary replay if the durable event prefix changes, or if suspension-time argument serialization drew from the VM's random stream
  • make the sandbox deterministic enough that a suspended VM provably cannot advance: sync crypto.subtle.digest, no Atomics.waitAsync/async-WebAssembly/WeakRef/FinalizationRegistry
  • WORKFLOW_RETAINED_VM=0 kill switch (default on) restores replay-from-scratch on every iteration
  • tag workflow.run spans with workflow.execution.mode=replay|retained for direct production decomposition
  • preserve runWorkflow as the one-shot compatibility API

Combines closes #2984 (retained-session architecture) with the best tactics from closes #2966 (env kill switch, loop-level single-VM A/B test), plus review-driven sandbox hardening.

Design

executeWorkflow owns the invocation-local VM behind two discriminated unions: a request (replay | resume) and a result (completed | suspended | replay). Overloads guarantee at the type level that a fresh replay can never request another replay — only resuming a stale or diverged session can.

The session itself is a small state machine: running, suspended, failed, replay, or completed. The runtime retains a session only for a pure step boundary — stepCount > 0, zero hooks/waits/attributes/hook-disposals/aborts, and no open hook or wait anywhere in the event history — since hooks and waits can wake concurrent invocations and attributes require replay.

Unconditional VM quiescence

Retention is only sound if a suspended VM cannot make progress the event log cannot replay (found by review: a workflow racing host-timed async against a step could advance while the inline step ran and commit behavior no replay reconstructs). Rather than tracking such APIs, the sandbox now has none:

  • crypto.subtle.digest computes synchronously via node:crypto (createHash — byte-identical values, verified against WebCrypto for SHA-1/256/384/512; stable and undeprecated through Node 26), so its promise settles on a deterministic microtask. Digest-using workflows stay retainable, and digest race timing becomes deterministic and identical across live, retained, and replay execution.
  • Atomics.waitAsync (a wall-clock timer via SharedArrayBuffer), the async WebAssembly compilation entry points, WeakRef, and FinalizationRegistry are deleted from the sandbox — wall-clock timing and GC observation are unreplayable with or without retention. Sync new WebAssembly.Module()/Instance() remain. Dynamic import() settles within a microtask.

A suspended VM is therefore parked exclusively on log-driven promises: its state is a pure function of the consumed event prefix — exactly what a replay rebuilds.

Serialization isolation (found by review, several rounds)

handleSuspension dehydrates step arguments with the live VM, and ordinary serialization can execute workflow code (getters, WORKFLOW_SERIALIZE hooks, spoofed dispatch) — side effects a later cold replay would not repeat. For retention candidates the inputs are therefore prepared without touching workflow code, all-or-nothing per batch:

  • a passive walker (retained-step-input.ts) accepts only primitives, plain objects, and plain arrays — the types devalue traverses purely via own-property reads — inspecting descriptors only (getters never fire, proxies/symbol tags/constructor/then decline);
  • accepted values are deep-copied into an SDK-private pristine realm, so the clone's prototype chain is immune to intrinsic mutation in both the workflow realm and the host realm;
  • the sandbox freezes every serialization-dispatch intrinsic workflow code could otherwise redefine (Object/Array/Function + prototypes, %TypedArray%, all non-shared serialization bindings; absent bindings pinned to undefined);
  • shared host intrinsics cannot be frozen process-wide, so their dispatch points are verified pristine (own-descriptor reads: no Symbol.hasInstance, no WORKFLOW_SERIALIZE/classId statics, unmodified constructor/toStringTag) before any clone exists — dirt declines retention for the boundary.

Any input that fails these checks serializes through the ordinary VM path and the session falls back to replay — behavior identical to WORKFLOW_RETAINED_VM=0.

Known residual (deliberate): workflow code that escapes to the host realm via exposed host-class constructor chains (e.g. TextEncoder.constructor) can schedule real timers and was never deterministic under ordinary replay either; that root cause needs realm-local sandbox intrinsics and is tracked as a follow-up.

On resume, the session verifies that every previously observed event is still an exact eventId prefix of the authoritative log, appends only the new events to the existing consumer, and wakes the suspended execution. A prefix mismatch or a second suspension boundary from an unobserved/discarded session moves it permanently to replay. Background workflow code can only enqueue in-memory work; the active observer remains the sole owner of durable writes and terminal drains.

This is intentionally invocation-local. Re-invocation, retry, rollover, queued execution, and process loss discard the session and use normal durable replay. The event log remains the source of truth.

Why the kill switch

Retained execution is expected to be observationally identical to a fresh replay, but that equivalence rests on VM-internal state (deterministic RNG stream position, ULID factory state, VM clock) the event-prefix guard cannot check. WORKFLOW_RETAINED_VM=0 gives targeted mitigation and incident bisection without a dependency rollback. The "off" path is not a second implementation — it forces the always-alive fallback that hooks, waits, and divergence already exercise in production. CI runs the full core suite in both modes, and retained-vm-loop.test.ts asserts the dehydrated run output is byte-identical between modes while the retained mode builds exactly one VM per run.

Validation

  • pnpm --filter @workflow/core typecheck
  • pnpm --filter @workflow/core build
  • full core unit suite in both modes (default and WORKFLOW_RETAINED_VM=0): 71 files, 1,485 passed, 3 expected failures each
  • retained-session coverage includes sequential suspensions, event-prefix divergence, late completion, discarded-session isolation, event-consumer quiescence, telemetry context restoration, and the loop-level single-VM byte-identical A/B test
  • simplify and quality-code passes completed
  • autoreview loop: 7 rounds of Codex gpt-5.6-sol xhigh; 5 findings accepted and fixed (host-async retention, host-async enumeration, GC intrinsics, digest BufferSource validation, serialization-randomness demotion); final round clean with zero findings

Benchmark

STSO results land in the sticky benchmark comment below (the benchmark workflow runs against this PR's preview deployment).

For reference, #2984 measured the same architecture (on top of #2980, now merged) at:

STSO window Cache only (#2980) Retained VM + cache Average change
Steps 1-20 286.6 ms 166.6 ms -41.9%
Steps 101-120 244.7 ms 176.9 ms -27.7%
Steps 1001-1020 556.4 ms 228.3 ms -59.0%

Docs Preview

Behind deployment protection (Vercel team access required).

Page Preview (v5)
Runtime Tuning — WORKFLOW_RETAINED_VM /v5/docs/configuration/runtime-tuning#workflow_retained_vm
Workflow Globals — sync digest, removed intrinsics /v5/docs/api-reference/workflow-globals#not-available
WORKFLOW_SERIALIZE — hook purity constraints /v5/docs/api-reference/workflow-serde/workflow-serialize

@NathanColosimo
NathanColosimo requested review from a team and ijjk as code owners July 17, 2026 20:39
@vercel

vercel Bot commented Jul 17, 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, Comment Jul 17, 2026 11:49pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jul 17, 2026 11:49pm
example-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-astro-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-express-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-fastify-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-hono-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-nitro-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-nuxt-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-sveltekit-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workbench-vite-workflow Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workflow-docs Ready Ready Preview, Comment, Open in v0 Jul 17, 2026 11:49pm
workflow-swc-playground Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workflow-tarballs Ready Ready Preview, Comment Jul 17, 2026 11:49pm
workflow-web Ready Ready Preview, Comment Jul 17, 2026 11:49pm

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

Passed Failed Skipped Total
✅ ▲ Vercel Production 1453 0 230 1683
✅ 💻 Local Development 1617 0 219 1836
✅ 📦 Local Production 1617 0 219 1836
✅ 🐘 Local Postgres 1617 0 219 1836
✅ 🪟 Windows 153 0 0 153
✅ 📋 Other 894 0 177 1071
✅ vercel-multi-region 27 0 0 27
Total 7378 0 1064 8442

Details by Category

✅ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 126 0 27
✅ example 126 0 27
✅ express 126 0 27
✅ fastify 126 0 27
✅ hono 126 0 27
✅ nextjs-turbopack 150 0 3
✅ nextjs-webpack 150 0 3
✅ nitro 126 0 27
✅ nuxt 126 0 27
✅ sveltekit 145 0 8
✅ vite 126 0 27
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 128 0 25
✅ express-stable 128 0 25
✅ fastify-stable 128 0 25
✅ hono-stable 128 0 25
✅ nextjs-turbopack-canary 134 0 19
✅ nextjs-turbopack-stable 153 0 0
✅ nextjs-webpack-canary 134 0 19
✅ nextjs-webpack-stable 153 0 0
✅ nitro-stable 128 0 25
✅ nuxt-stable 128 0 25
✅ sveltekit-stable 147 0 6
✅ vite-stable 128 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 153 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 128 0 25
✅ e2e-local-dev-tanstack-start- 128 0 25
✅ e2e-local-postgres-nest-stable 128 0 25
✅ e2e-local-postgres-tanstack-start- 128 0 25
✅ e2e-local-prod-nest-stable 128 0 25
✅ e2e-local-prod-tanstack-start- 128 0 25
✅ e2e-vercel-prod-tanstack-start 126 0 27
✅ vercel-multi-region
App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 3498706 · Mon, 20 Jul 2026 18:49:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1272 (+22%) 1244 1296 🔴 1322 🔴 1370 🔴 30
TTFS stream 1306 (+60%) 1253 1300 🔴 1414 🔴 1579 🔴 30
TTFS hook + stream 1508 (+42%) 1449 1524 🔴 1573 🔴 1873 🔴 30
STSO 1020 steps (1-20) 143 (-43%) 121 145 🔴 166 🔴 272 🔴 19
STSO 1020 steps (101-120) 142 (-46%) 114 149 🔴 152 🔴 323 🔴 19
STSO 1020 steps (1001-1020) 145 (-74%) 115 152 🔴 224 🔴 259 🔴 19
WO 1020 steps 152296 (-65%) 152296 152296 152296 152296 1
SL stream latency 146 (-7.7%) 110 168 🔴 188 🔴 270 🔴 30
📜 Previous results (9)

3498706

Fri, 17 Jul 2026 23:54:22 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1262 (+20%) 1216 1273 🔴 1296 🔴 1669 🔴 30
TTFS stream 1259 (+26%) 1220 1275 🔴 1292 🔴 1321 🔴 30
TTFS hook + stream 1507 (+19%) 1462 1567 🔴 1600 🔴 1669 🔴 30
STSO 1020 steps (1-20) 151 (-37%) 114 168 🔴 276 🔴 300 🔴 19
STSO 1020 steps (101-120) 129 (-50%) 119 138 🔴 150 🔴 151 🔴 19
STSO 1020 steps (1001-1020) 124 (-75%) 109 134 🔴 145 🔴 152 🔴 19
WO 1020 steps 133240 (-66%) 133240 133240 133240 133240 1
SL stream latency 120 (-21%) 91 131 🔴 144 🔴 183 🔴 30

e961466

Fri, 17 Jul 2026 23:40:11 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1285 (+22%) 1167 1352 🔴 1373 🔴 1439 🔴 30
TTFS stream 1231 (+23%) 1169 1263 🔴 1330 🔴 1371 🔴 30
TTFS hook + stream 1607 (+27%) 1376 1506 🔴 1692 🔴 5305 🔴 30
STSO 1020 steps (1-20) 167 (-30%) 115 199 🔴 287 🔴 404 🔴 19
STSO 1020 steps (101-120) 130 (-50%) 108 131 🔴 155 🔴 226 🔴 19
STSO 1020 steps (1001-1020) 143 (-72%) 118 134 🔴 208 🔴 309 🔴 19
WO 1020 steps 135972 (-66%) 135972 135972 135972 135972 1
SL stream latency 149 (-1.4%) 100 163 🔴 206 🔴 383 🔴 30

2b73676

Fri, 17 Jul 2026 23:10:31 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1390 (+32%) 1311 1377 🔴 1426 🔴 2501 🔴 30
TTFS stream 1340 (+34%) 1311 1364 🔴 1388 🔴 1414 🔴 30
TTFS hook + stream 1437 (+13%) 1388 1438 🔴 1561 🔴 1615 🔴 30
STSO 1020 steps (1-20) 144 (-40%) 121 146 🔴 162 🔴 283 🔴 19
STSO 1020 steps (101-120) 135 (-48%) 119 144 🔴 156 🔴 172 🔴 19
STSO 1020 steps (1001-1020) 125 (-75%) 116 129 🔴 133 🔴 153 🔴 19
WO 1020 steps 138330 (-65%) 138330 138330 138330 138330 1
SL stream latency 126 (-16%) 97 138 🔴 144 🔴 208 🔴 30

43788b8

Fri, 17 Jul 2026 22:52:28 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1316 (+19%) 1268 1328 🔴 1351 🔴 1424 🔴 30
TTFS stream 1294 (+18%) 1253 1309 🔴 1333 🔴 1389 🔴 30
TTFS hook + stream 1546 (+5.6%) 1477 1579 🔴 1634 🔴 1839 🔴 30
STSO 1020 steps (1-20) 149 (-48%) 125 146 🔴 191 🔴 293 🔴 19
STSO 1020 steps (101-120) 143 (-56%) 123 149 🔴 183 🔴 191 🔴 19
STSO 1020 steps (1001-1020) 153 (-74%) 127 160 🔴 235 🔴 316 🔴 19
WO 1020 steps 145010 (-66%) 145010 145010 145010 145010 1
SL stream latency 158 (-6.8%) 113 172 🔴 194 🔴 485 🔴 30

3bda890

Fri, 17 Jul 2026 22:33:42 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1371 (+24%) 1303 1401 🔴 1457 🔴 1525 🔴 30
TTFS stream 1335 (+21%) 1288 1363 🔴 1388 🔴 1450 🔴 30
TTFS hook + stream 1594 (+8.8%) 1520 1604 🔴 1746 🔴 1833 🔴 30
STSO 1020 steps (1-20) 151 (-47%) 121 170 🔴 207 🔴 251 🔴 19
STSO 1020 steps (101-120) 161 (-50%) 120 166 🔴 220 🔴 371 🔴 19
STSO 1020 steps (1001-1020) 132 (-78%) 116 141 🔴 146 🔴 160 🔴 19
WO 1020 steps 151272 (-65%) 151272 151272 151272 151272 1
SL stream latency 170 (±0%) 127 182 🔴 199 🔴 423 🔴 30

c6d6cb5

Fri, 17 Jul 2026 22:02:43 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1346 1287 1364 🔴 1405 🔴 1647 🔴 30
TTFS stream 1310 1265 1330 🔴 1353 🔴 1409 🔴 30
TTFS hook + stream 1691 1488 1605 🔴 1650 🔴 5676 🔴 30
STSO 1020 steps (1-20) 165 122 166 🔴 299 🔴 348 🔴 19
STSO 1020 steps (101-120) 145 117 149 🔴 203 🔴 246 🔴 19
STSO 1020 steps (1001-1020) 153 113 161 🔴 213 🔴 291 🔴 19
WO 1020 steps 144389 144389 144389 144389 144389 1
SL stream latency 157 115 171 🔴 210 🔴 323 🔴 30

bd07e11

Fri, 17 Jul 2026 21:34:21 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1310 (+1.4%) 1634 🔴 1734 🔴 1910 🔴 30
TTFS hook + stream 1434 (±0%) 1859 🔴 1956 🔴 1993 🔴 30
STSO 1020 steps (1-20) 173 (-34%) 189 🔴 275 🔴 299 🔴 19
STSO 1020 steps (101-120) 161 (-55%) 167 🔴 291 🔴 314 🔴 19
STSO 1020 steps (1001-1020) 154 (-72%) 164 🔴 173 🔴 239 🔴 19
WO stream 1310 (+1.4%) 1634 1734 1910 30
WO hook + stream 1434 (±0%) 1859 1956 1993 30
SL stream 3608 (+152%) 5604 🔴 5698 🔴 6129 🔴 30
SL hook + stream 3723 (+47%) 5474 🔴 5559 🔴 6004 🔴 30

93d5afc

Fri, 17 Jul 2026 21:06:35 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1321 (+4.3%) 1616 🔴 1710 🔴 1843 🔴 30
TTFS hook + stream 1770 (+33%) 1905 🔴 2086 🔴 5356 🔴 30
STSO 1020 steps (1-20) 195 (-35%) 216 🔴 281 🔴 400 🔴 19
STSO 1020 steps (101-120) 182 (-41%) 213 🔴 278 🔴 376 🔴 19
STSO 1020 steps (1001-1020) 158 (-75%) 171 🔴 214 🔴 219 🔴 19
WO stream 1321 (+4.3%) 1616 1710 1843 30
WO hook + stream 1770 (+33%) 1905 2086 5356 30
SL stream 3144 (+214%) 5764 🔴 5807 🔴 5881 🔴 30
SL hook + stream 3882 (+99%) 5628 🔴 5649 🔴 6040 🔴 30

b32774f

Fri, 17 Jul 2026 20:51:27 GMT · run logs

vercel / nextjs-turbopack

Metric Scenario Avg (ms) P10 (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS stream 1381 (+9.1%) 1602 🔴 1650 🔴 1801 🔴 30
TTFS hook + stream 1541 (+16%) 1843 🔴 1964 🔴 2179 🔴 30
STSO 1020 steps (1-20) 167 (-44%) 168 🔴 286 🔴 319 🔴 19
STSO 1020 steps (101-120) 166 (-46%) 178 🔴 263 🔴 281 🔴 19
STSO 1020 steps (1001-1020) 156 (-75%) 171 🔴 180 🔴 181 🔴 19
WO stream 1381 (+9.1%) 1602 1650 1801 30
WO hook + stream 1541 (+16%) 1843 1964 2179 30
SL stream 4546 (+355%) 5675 🔴 5792 🔴 5865 🔴 30
SL hook + stream 4134 (+112%) 5556 🔴 5653 🔴 5784 🔴 30

Avg deltas compare against the most recent benchmark run on main at the time of this run.

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)

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)

🟢/🔴 mark percentiles within/above target. Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · STSO (1-20) 20/30/60 · STSO (101-120) 30/45/90 · STSO (1001-1020) 40/60/120

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 P10 column shows the warm-start floor for comparison.

@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3498706

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

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

@NathanColosimo
NathanColosimo force-pushed the nathanc/workflow-replay-perf-43e0a8 branch from 5febf4e to fd73574 Compare July 17, 2026 21:19
- A mixed step batch (one unsafe sibling input) now serializes every
  input through the ordinary VM path: a clone snapshotted before an
  unsafe sibling's serialization runs its getters could otherwise
  durably capture stale sibling state.
- crypto.subtle.digest rejects SharedArrayBuffer-backed views with
  TypeError, matching WebCrypto's BufferSource contract.
NathanColosimo and others added 9 commits July 17, 2026 14:43
devalue serializes Map/Set through the realm's iterator protocol and
Date/RegExp/typed arrays through prototype getters, all of which
workflow code can mutate — so their serialization is not provably
passive and their bytes could differ between retained and cold modes.
The fast path now accepts only primitives, plain objects, and plain
arrays, which devalue traverses exclusively via own-property reads.
Slot-bearing exotics decline even with a swapped prototype.

The sandbox digest now reads view metadata (buffer/byteOffset/
byteLength) through captured intrinsic getters, so own properties
shadowing them cannot change which bytes are hashed or bypass the
SharedArrayBuffer rejection.
instanceof dispatch (Symbol.hasInstance via the constructor,
Function.prototype, and Object.prototype), the class reducer's
value.constructor walk, and devalue's Object/Array traversal all consult
intrinsics workflow code could redefine — legally and deterministically —
which would make the durable step input depend on WORKFLOW_RETAINED_VM
(spoofed values serialize as e.g. Maps on the cold path but as plain
clones on the retained path). Freeze Object/Array/Function (constructors
and prototypes), the VM collection constructors, and every
reducer-referenced global binding (absent ones pinned to undefined)
right before the workflow bundle evaluates, so the retained-input
equivalence holds by construction.

Host-realm constructor escapes (e.g. TextEncoder.constructor) remain
out of the determinism contract: code scheduling host timers was never
deterministic under ordinary replay either; documented on
canRetainWorkflowSession.
Typed-array constructors (and their shared %TypedArray% parent), the
Date wrapper, and the session-local AbortController/AbortSignal/
Request/Response bindings were pinned but not frozen, so workflow code
could still add Symbol.hasInstance statics that diverge reducer dispatch
between the retained clone (host constructors) and ordinary VM
serialization. Freeze every binding value that is not the shared host
intrinsic; shared host objects are dispatched identically by both paths,
so mutations there cannot cause mode divergence.
Replace structuredClone with an explicit deep copy into an SDK-private
realm: clones previously inherited host prototypes, which workflow code
can reach (e.g. via structuredClone's return values) and vandalize with
Symbol.toStringTag or constructor overrides, shifting devalue's
classification of the clone relative to the ordinary VM path. The
pristine realm is unreachable by any user code, and the explicit copy
serializes exactly what devalue traverses (own indices, own enumerable
string props). Arrays also now decline own constructor properties,
which the class reducer reads even when non-enumerable.
Host intrinsics are shared with the whole process and cannot be frozen,
but workflow code can reach them (structuredClone results, exposed host
classes) and install Symbol.hasInstance predicates that distinguish the
original from its clone — or WORKFLOW_SERIALIZE statics on host
Object/Array that the class reducer reads for host-prototype originals
(hydrated step results). prepareRetainedStepInput now verifies, via
own-descriptor reads only, that every host dispatch point is pristine
and declines retention before any clone exists — so a spoofed predicate
can never observe or capture a pristine-realm object.
Reducers dispatch on symbol tags (e.g. the workflow abort-signal
markers) that are non-enumerable and dropped by the pristine-realm
copy, so a tagged object would serialize as an abort descriptor on the
cold path but as plain data on the retained path.
Hidden own keys of any kind — non-enumerable properties, accessors,
symbols — can be observed by serialization dispatch (reducer probes
like .signal, thenable checks, the class reducer) while the pristine
clone drops them. With no hidden own keys, every probe on an accepted
object resolves deterministically through validated data or pristine
prototypes.
Symbol.hasInstance dispatch walks the constructor's prototype chain, so
the frozen Date wrapper still exposed the unfrozen original VM Date it
delegates statics to. Freeze each non-shared binding's full chain
(stopping at host Function/Object prototypes) and verify host
Object.prototype carries no added hasInstance on the detection side.
@VaguelySerious

Copy link
Copy Markdown
Member

I'm re-running benchmarks one more time to see if the TTFS downsides hold

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants