You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
observe flushable stream-state rejections immediately so failed writes are still propagated through runtime operations without first surfacing as unhandledRejection
include the stream PUT endpoint and safe Vercel response correlation headers (x-vercel-id, x-vercel-error) in write/close failure errors while preserving the response body
add patch changesets for @workflow/core and @workflow/world-vercel
Root cause
A streaming step can fail a server write while user code is still running. flushablePipe() rejects state.promise, but the step runtime only waits on its collected operation promises after the step body returns. During that gap Node can classify the rejection as unhandled, allowing a transient stream failure to terminate the invocation instead of being handled through the runtime error path.
This PR attaches an immediate no-op rejection observer to the existing promise while leaving that original rejected promise in place for the runtime to await and propagate normally.
Retry behavior
This intentionally does not add automatic retries for failed stream writes. Writes are not currently idempotent, and a response failure after server acceptance cannot be distinguished from a failed write; blind retries can duplicate stream chunks.
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Prevents stream write failures from surfacing as unhandledRejection before the step runtime collects them via ops, and enriches Vercel stream HTTP errors with the request endpoint and Vercel correlation headers.
Changes:
Attach a no-op .catch() to FlushableStreamState.promise at creation so early rejections are observed before the runtime awaits them.
Centralize Vercel stream write/close error construction in createStreamRequestError(), including PUT URL plus x-vercel-id / x-vercel-error headers.
Add tests for unhandled-rejection behavior and Vercel error diagnostics, plus a patch changeset.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
File
Description
packages/core/src/flushable-stream.ts
Adds no-op rejection observer on state.promise in createFlushableState.
packages/core/src/flushable-stream.test.ts
New test verifying no unhandledRejection is emitted on early state rejection.
packages/world-vercel/src/streamer.ts
Adds createStreamRequestError helper; uses it in write/writeMulti/close failures.
packages/world-vercel/src/streamer.test.ts
New test asserting endpoint and Vercel correlation headers appear in the failure message.
.changeset/stream-failure-diagnostics.md
Patch changeset for @workflow/core and @workflow/world-vercel.
The reason will be displayed to describe this comment to others. Learn more.
Approve — correct fix for a real bug, with a strong regression test
Two changes:
createFlushableState attaches a no-op .catch(() => {}) to state.promise at creation time, so an early rejection (before the runtime collects state.promise into its ops array and awaits it) doesn't fire unhandledRejection.
world-vercel stream errors now include PUT <url> + x-vercel-id + x-vercel-error headers in the error message — useful for tracing transient stream failures back to a specific Vercel function invocation.
Why this fix is correct
The .catch(() => {}) is a standard Node.js pattern for "this rejection will be observed later." It does not swallow the error:
.catch() returns a NEW promise that resolves (because the handler returned undefined).
The original state.promise is still rejected and still surfaces the same rejection when later awaited.
Both .then()/.catch() chains on the same promise observe the same rejection — they don't compete.
I traced where state.promise is consumed: serialization.ts (×6 sites) and writable-stream.ts all ops.push(state.promise), and the runtime does await Promise.all(ops) after user code returns. So the rejection still propagates through the normal error path. ✓
Test design is excellent
The new test (does not emit an unhandled rejection before the runtime awaits a failed operation):
Listens for process.on('unhandledRejection').
Creates state, rejects it, waits a tick (long enough for Node to fire unhandledRejection if it would).
Asserts no unhandledRejection fired AND state.promise still rejects when awaited.
The second assertion is critical — it proves the fix didn't accidentally swallow the rejection.
I verified the test fails without the fix by temporarily removing the .catch() line:
So the test correctly catches the regression it's protecting against.
What I verified locally
pnpm install --frozen-lockfile ✓
pnpm turbo run build --filter @workflow/core --filter @workflow/world-vercel ✓
cd packages/core && pnpm exec vitest run src/flushable-stream.test.ts ✓ (10/10)
pnpm --filter @workflow/world-vercel test ✓ (110/110)
Reverted the .catch(() => {}) line and confirmed the regression test fails as expected — proves the test isn't a vacuous pass.
CI
106 success, 5 failures, all unrelated:
E2E Vercel Prod Tests (vite) — single workflow run flake on Vercel infrastructure
Benchmark Vercel/Local (nitro-v3) — main also fails these regularly
Benchmark Community World (Redis + BullMQ) — unrelated to flushable-stream
E2E Required Check — meta-check failing because the above failed
Restraint on retry is the right call
The PR body's note about not adding automatic retries is exactly right. Stream writes aren't idempotent server-side, and a response error after server-accepted bytes can't be distinguished from a pre-accept network failure — blindly retrying could duplicate chunks. The diagnostic improvement (request correlation headers in the error) is the better incremental win: now when a stream write fails, the user has the Vercel request ID to look up server-side state.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
unhandledRejectionPUTendpoint and safe Vercel response correlation headers (x-vercel-id,x-vercel-error) in write/close failure errors while preserving the response body@workflow/coreand@workflow/world-vercelRoot cause
A streaming step can fail a server write while user code is still running.
flushablePipe()rejectsstate.promise, but the step runtime only waits on its collected operation promises after the step body returns. During that gap Node can classify the rejection as unhandled, allowing a transient stream failure to terminate the invocation instead of being handled through the runtime error path.This PR attaches an immediate no-op rejection observer to the existing promise while leaving that original rejected promise in place for the runtime to await and propagate normally.
Retry behavior
This intentionally does not add automatic retries for failed stream writes. Writes are not currently idempotent, and a response failure after server acceptance cannot be distinguished from a failed write; blind retries can duplicate stream chunks.
Validation
pnpm exec biome check --write packages/core/src/flushable-stream.ts packages/core/src/flushable-stream.test.ts packages/world-vercel/src/streamer.ts packages/world-vercel/src/streamer.test.tspnpm --filter @workflow/core... buildWORKFLOW_TARGET_WORLD=local pnpm --filter @workflow/core exec vitest run src(1,034 tests passed)pnpm --filter @workflow/world-vercel exec vitest run src(110 tests passed)pnpm changeset status --since=origin-https/maingit diff --check