Repository navigation
fix(core): start forwarded writable key lookup on first write - #4175
Conversation
Hydrating a payload that carries a writable forwarded from another run called `getForwardedWritableEncryptionKey()` inside the reviver and stored the promise it returned. Nothing awaits that promise until the first chunk is written, so when the lookup failed first — e.g. the `runs.get` fallback for a descriptor that carries no deployment id or public key timing out — the rejection had no handler and Node exited the process. A caller that never touched the stream lost its whole invocation, and the symptom presented as an unexplained function failure rather than a slow read. Wrap the lookup in a memoized thunk instead. `EncryptionKeyParam` already accepts a resolver, so the lookup now starts on the first write and a failure rejects that stream, reaching the writer as the `cause` on `writer.closed`. Both reviver boundaries are covered: the client one (`getExternalRevivers`, reached from `await run.returnValue`) and the step one (`getStepRevivers`). This matches how the readable side and `Run#getWritable` already defer the same lookup. Fixes #3935 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
🦋 Changeset detectedLatest commit: 44733e5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3662 | 0 | 685 | 4347 |
| ✅ 💻 Local Development | 3998 | 0 | 510 | 4508 |
| ✅ 📦 Local Production | 3998 | 0 | 510 | 4508 |
| ✅ 🐘 Local Postgres | 3998 | 0 | 510 | 4508 |
| ✅ 🪟 Windows | 320 | 0 | 2 | 322 |
| ✅ 🌐 Cross-language Conformance | 68 | 0 | 74 | 142 |
| ✅ vercel-http-transport | 823 | 0 | 143 | 966 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| ✅ vercel-ws-transport | 557 | 0 | 87 | 644 |
| Total | 17451 | 0 | 2521 | 19972 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 133 | 0 | 28 |
| ✅ astro-quickjs | 133 | 0 | 28 |
| ✅ example-node | 133 | 0 | 28 |
| ✅ example-quickjs | 133 | 0 | 28 |
| ✅ express-node | 133 | 0 | 28 |
| ✅ express-quickjs | 133 | 0 | 28 |
| ✅ fastify-node | 133 | 0 | 28 |
| ✅ fastify-quickjs | 133 | 0 | 28 |
| ✅ hono-node | 133 | 0 | 28 |
| ✅ hono-quickjs | 133 | 0 | 28 |
| ✅ nest-node | 133 | 0 | 28 |
| ✅ nest-quickjs | 133 | 0 | 28 |
| ✅ nextjs-turbopack-node | 158 | 0 | 3 |
| ✅ nextjs-turbopack-quickjs | 158 | 0 | 3 |
| ✅ nextjs-webpack-node | 158 | 0 | 3 |
| ✅ nextjs-webpack-quickjs | 158 | 0 | 3 |
| ✅ nitro-node | 133 | 0 | 28 |
| ✅ nitro-quickjs | 133 | 0 | 28 |
| ✅ nuxt-node | 133 | 0 | 28 |
| ✅ nuxt-quickjs | 133 | 0 | 28 |
| ✅ python-node | 66 | 0 | 95 |
| ✅ sveltekit-node | 152 | 0 | 9 |
| ✅ sveltekit-quickjs | 152 | 0 | 9 |
| ✅ tanstack-start-node | 133 | 0 | 28 |
| ✅ tanstack-start-quickjs | 133 | 0 | 28 |
| ✅ vite-node | 133 | 0 | 28 |
| ✅ vite-quickjs | 133 | 0 | 28 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs | 160 | 0 | 1 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 68 | 0 | 74 |
✅ vercel-http-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 133 | 0 | 28 |
| ✅ express | 133 | 0 | 28 |
| ✅ hono | 133 | 0 | 28 |
| ✅ nextjs-turbopack | 158 | 0 | 3 |
| ✅ nitro | 133 | 0 | 28 |
| ✅ vite | 133 | 0 | 28 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
✅ vercel-ws-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 133 | 0 | 28 |
| ✅ express | 133 | 0 | 28 |
| ✅ nextjs-turbopack | 158 | 0 | 3 |
| ✅ vite | 133 | 0 | 28 |
📊 Workflow Benchmarkscommit Backend:
Streams
📈 STSO distribution vs main (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: main 158739ms → this run 189531ms (Δ +30792ms, +19%) 📈 CRTT drill-down vs main (RTT distributions & profiles)RTT over stream progress (avg per tenth of stream, bars scaled min→max): RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max): Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max): 📜 Previous results (1)44733e5Tue, 15 Sep 2026 08:58:08 GMT · run logs
Streams
ℹ️ Metric definitions & methodologyStreams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach. The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, Best/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it) 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 · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 41 total
Full trace: |
About these numbersSizes are gzip; parentheses show the change against
|
CI triage — both failures are unrelated to this changeThe run finished with exactly two red jobs and no others. Neither is caused by this diff, and neither executed a test that this diff can reach. My token can't 1. Failed at step 6, "Deploy a dedicated WS-transport preview". Steps 7 ( The API returned an HTML error page instead of JSON during file upload. This lane passed on the base commit ( 2. Both assert It passes locally on this branch: Worth noting this is a known-flaky file: #4119 ("Fix three crash-class defects behind this week's flaky CI") is currently modifying Everything this PR can affect is green, including |
|
Reopening to retrigger CI — the only red is a known-flaky Windows perf assertion in |
✅ CI is green — 180 passing, 0 failingRetriggered twice. Both earlier reds were flakes, and each retry produced a different failure, which is itself the tell — a real regression fails the same test every time.
Three things confirm none of it was this diff:
Only |
…rt of #4175) (#4176) * fix(core): start forwarded writable key lookup on first write Backport of the `main` fix for #3935, hand-ported because the 4.8.x `getForwardedWritableEncryptionKey` takes two parameters and returns a `CryptoKey`, so the commit does not cherry-pick cleanly. Hydrating a payload that carries a writable forwarded from another run called `getForwardedWritableEncryptionKey()` inside the reviver and stored the promise it returned. Nothing awaits that promise until the first chunk is written, so when the lookup failed first — e.g. the `runs.get` fallback for a descriptor from an older deployment timing out — the rejection had no handler and Node exited the process. A caller that never touched the stream lost its whole invocation. Wrap the lookup in a memoized thunk instead. This is the resolver form `EncryptionKeyParam` already documents ("avoids unobserved background lookups for empty or never-read streams"), so the lookup starts on the first write and a failure rejects that stream. Both reviver boundaries are covered: the client one (`getExternalRevivers`) and the step one (`getStepRevivers`). This line is more exposed than `main`: it has no `encryptionPublicKey` fast path, so every cross-run forwarded writable takes the failing `runs.get` path. Fixes #3935 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * test(core): assert the timeout cause on stable, matching the main PR The backport asserted only that `writer.closed` rejects. Assert the same `cause: { code: 'TIMEOUT' }` the `main` test does, so both branches pin that the world error survives to the writer rather than just that some error does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> --------- Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
|
Backport to This is usually an infrastructure problem (e.g. the configured AI model could not be found, an AI Gateway error, or an opencode crash) rather than a merge conflict. Check the job logs linked above for details. Once the underlying issue is fixed, re-run the Backport to stable workflow manually via |
Description
Fixes #3935.
When a step's arguments or a run's return value contain a writable forwarded from another run, the reviver called
getForwardedWritableEncryptionKey()immediately and stored the promise it returned. The only consumer of that promise is the serialize transform, which awaits it on the first chunk written — so on a writable nobody writes to, nothing ever observes it. When the lookup failed first, Node saw an unhandled rejection and killed the process.The lookup's slow path is the one that fails: for a descriptor carrying no
deploymentIdand noencryptionPublicKeyit falls back toworld.runs.get(ownerRunId), which isGET /v2/runs/:id?remoteRefBehavior=resolve— exactly the request in the report, timing out after ~72s. Only the Vercel World implementsgetEncryptionKeyForRun, so this is Vercel-only; local and Postgres return early and never make the request.The cost lands on whatever else shared the invocation. In #3935 that was a GitHub webhook handler with no relationship to the run being resolved, and the symptom presented as an unexplained function failure rather than "reading run metadata was slow."
The fix: wrap the lookup in a memoized thunk.
EncryptionKeyParamalready accepts a resolver, so the lookup starts on the first write, runs at most once, and a failure rejects that stream — reaching the writer as thecauseonwriter.closed, cause chain intact:Both reviver boundaries are covered:
getExternalRevivers(the client path, reached fromawait run.returnValue) andgetStepRevivers(forwarded writables in step arguments). This is the same shape the readable side already uses (resolveKeyin theReadableStreamreviver, #3871) and thatRun#getWritableuses (resolveTarget) — the writable reviver was the one place still resolving eagerly.Trade-off: forwarded writables without a public key now make their requests on the first write instead of overlapping them with execution, and writables that are never written to make none at all. That prefetch only ever helped descriptors minted by deployments predating public keys on the wire — the case that disappears as deployments roll forward — so paying it lazily is the right side of the trade.
Why this should land before #3678 (lifecycle hooks) — thanks @pranaygp for the pointer
#3678 makes this defect much more reachable, and its own design already argues for this fix.
dispatchRunFailedHookshydrates the persisted error payload so handlers can inspect it, and it does so deliberately inertly:Those two options exist for precisely the reason this PR exists — a reporting handler may never consume what it is handed, so readables must not open I/O and abort signals must not take out live subscriptions. #3678 correctly covers
ReadableStream,AbortControllerandAbortSignal. TheWritableStreamreviver is the one it leaves eager (it appears in that diff only as context), so a forwarded writable in a failed run's payload still firesruns.getduring hydration.That turns a conditional crash into a systematic one:
run_failedin any app that registers hooks, not only when user code happens to hydrate such a payload;safeWaitUntilaround the dispatch does not help. It guards the dispatch promise; the orphaned key promise is created inside the reviver and is never part of that chain.So an app that adopts
registerLifecycleHooksto report failures to Sentry would gain a new way to lose the invocation while reporting a failure. Landing this first means #3678 needs no change: with the thunk in place,lazyStreams/liveAbortSignalsand the writable path all agree that terminal reporting does no I/O it wasn't asked for.Worth considering as a follow-up on #3678 (not required by this PR): an explicit test that hydrating a
run_failedpayload containing a forwarded writable makes no requests, so the invariant is pinned on that side too.Relationship to #3955
@withkarann opened #3955 with the same diagnosis and the same thunk approach on 2026-09-03. It has gone stale against
main— its diff context still has the two-parameter, non-exportedgetForwardedWritableEncryptionKey, whereasmainnow exports it with a thirdencryptionPublicKeyparameter — and it had no companion forstable, where the exposure is larger (see below).This PR is that fix rebased onto current
main, plus regression tests for both reviver boundaries, the memoization guarantee, and the #3678 analysis above. Credit for the original diagnosis is theirs; happy to close whichever of the two is more convenient for the team.Scope and history
@workflow/core@5.0.0-beta.11, backported to the 4.8.x line as Backport #2191: fix forwarded stream encryption across deployments #2202. Not a recent regression.main(5.0.0-beta.52) andstable(4.8.8) before this change.stableis more exposed, which is why it gets its own PR rather than waiting on the backport bot: the 4.8.x line has noencryptionPublicKeyfast path at all, so every cross-run forwarded writable takes the failingruns.getpath. Companion PR: fix(core): start forwarded writable key lookup on first write (backport of #4175) #4176. The auto-backport for this commit can be closed as a duplicate.71779msagainst a 60sWORKFLOW_REQUEST_TIMEOUT_MSdefault, even though the deadline is armed per-attempt withelapsedmeasured from the same per-attemptfetchStart. That ~12s overshoot looks like event-loop starvation delaying the timer inside a busy invocation (consistent with two runs timing out in one invocation), or a raised timeout in their project. Orthogonal to the crash — the reporter is pursuing the underlying slowness through support.How did you test your changes?
Four regression tests in
packages/core/src/serialization.test.ts, alongside the existing forwarded-writable tests:unhandledRejection, and no request at allcauseonwriter.closedwithcode: 'TIMEOUT'Each mocks
runs.getto reject with the exactWorkflowWorldError { code: 'TIMEOUT' }shapeworld-vercel'smakeRequestraises.Verified the tests fail without the fix. Reverting only the two call sites (keeping the tests) fails both unhandled-rejection tests with
AssertionError: expected [ Array(1) ] to deeply equal [], the array holding the timeout message. The other two pass either way by design — they pin behaviour the fix must preserve.Also reproduced and fixed at the process level. A standalone script installs a fake World on
globalThis[Symbol.for('@workflow/world//cache')]whoseruns.getrejects like a timed-out read, hydrates a payload with a forwarded writable, and never writes to it — no stubbing inside@workflow/core, the crash comes from builtdist:world.runs.getcalledtruefalseERR_UNHANDLED_REJECTION, exit 1The error printed before the fix matches the report field for field (
status: undefined,code: 'TIMEOUT', the?remoteRefBehavior=resolveURL,retryAfter: undefined,[cause]TimeoutError). Exit is1under bare Node; Vercel's runtime wrapper reports the same condition as128.Full suites green on this branch:
packages/coreunit: 2447 passed, 3 expected-fail, 1 skipped (114 files)pnpm --filter @workflow/core typecheck: cleanbiome checkon both changed files: no new findings (13 pre-existing warnings, identical tomain)PR Checklist - Required to merge
pnpm changesetwas run to create a changelog for this PRgit commit --signoffon your commits)@vercel/workflowin a comment once the PR is ready, and the above checklist is complete🤖 Generated with Claude Code