fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections - #2873

Merged
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream
Jul 10, 2026
Merged

fix(world-vercel): cancel v4 event frame stream on early exit to release undici connections#2873
VaguelySerious merged 2 commits into
mainfrom
peter/reapply-cancel-v4-frame-stream

Conversation

@VaguelySerious

@VaguelySeriousVaguelySerious commented Jul 10, 2026

Copy link
Copy Markdown
Member

Summary

Reapplies #2547, which was reverted in #2554 with no recorded rationale.

decodeFrames (the v4 event frame-stream reader) never cancelled its underlying response.body when a consumer stopped reading before EOF. With Node's fetch/undici, a response body that is neither fully drained nor cancelled keeps its socket checked out of the connection pool — so every such read leaks a connection until keepalive timeout/GC.

Two live read paths stop early by design and hit this:

CallerEarly exitFrequency
getEventV4returns after the first frame (single-frame response)per event read
consumeListFrameStream (getWorkflowRunEventsV4 / getEventsByCorrelationIdV4)breaks at the {_end:1} sentinelper event-list, i.e. ~every replay

Fix

packages/world-vercel/src/frames.ts:

  • Wrap the decodeFrames read loop in try/finally and call chunks.return?.() on exit, which cancels the source stream (releasing the socket) on early break/return, normal completion, and error paths alike. No-op once drained.
  • readerToIterator now cancels its reader in a finally (the non-async-iterable fallback branch).
  • The cancel is fire-and-forget, not awaited (see "Why not a straight reapply" below).

Why not a straight reapply

The original #2547 diff awaited the cancel inside the finally block. Pushing that verbatim onto current main first (see commit history) hung every singleE2E Vercel Prod Tests (nextjs-turbopack) and (nextjs-webpack) run for the full 30-minute CI window — each run's step completed (step_completed observed) but the run never reached a terminal event, because the SDK's own subsequent event read was blocked awaiting the cancel. All 10 other frameworks passed cleanly in ~15 minutes.

This is the same failure signature and the same two CI lanes as #2807 ([world-local] Fix per-step AbortSignal latency), where an awaited reader.cancel() in the core abort-stream reader hung specifically on Next.js's Vercel Function lanes because a service-backed World's cancel() can hit the network, and Next.js's runtime doesn't tolerate blocking on it the way other frameworks do. That PR's fix — fire-and-forget the cancel — is applied here too.

This is very likely the actual (undocumented) reason #2547 was reverted in June, before that failure mode had a name.

Test plan

  • packages/world-vercel/src/frames.test.ts: asserts the underlying stream is cancelled when the consumer breaks early (for both the async-iterable and readerToIterator branches), plus a guard that full consumption still decodes every frame. These pass unchanged with the fire-and-forget cancel — invoking .cancel()/.return() runs the underlying source's cancel algorithm synchronously; only awaiting completion is deferred.
  • packages/world-vercel/src/events-v4.test.ts: getEventV4 HTTP round-trip via undici MockAgent, with a trailing frame the reader must never read.
  • Locally: pnpm test (world-vercel) 235 passed, pnpm typecheck and biome check clean.
  • CI: pushed the straight reapply first, confirmed the Next.js-only hang empirically, then pushed the fire-and-forget fix as a second commit.

@VaguelySerious
VaguelySerious requested review from a team and ijjk as code ownersJuly 10, 2026 18:17
@vercel

vercelBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@changeset-bot

changeset-botBot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e3474ec

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

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

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

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

Summary

PassedFailedSkippedTotal
✅ ▲ Vercel Production145302301683
✅ 💻 Local Development161702191836
✅ 📦 Local Production161702191836
✅ 🐘 Local Postgres161702191836
✅ 🪟 Windows15300153
✅ 📋 Other89401771071
Total7351010648415

Details by Category

✅ ▲ Vercel Production
AppPassedFailedSkipped
✅ astro126027
✅ example126027
✅ express126027
✅ fastify126027
✅ hono126027
✅ nextjs-turbopack15003
✅ nextjs-webpack15003
✅ nitro126027
✅ nuxt126027
✅ sveltekit14508
✅ vite126027
✅ 💻 Local Development
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 📦 Local Production
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🐘 Local Postgres
AppPassedFailedSkipped
✅ astro-stable128025
✅ express-stable128025
✅ fastify-stable128025
✅ hono-stable128025
✅ nextjs-turbopack-canary134019
✅ nextjs-turbopack-stable15300
✅ nextjs-webpack-canary134019
✅ nextjs-webpack-stable15300
✅ nitro-stable128025
✅ nuxt-stable128025
✅ sveltekit-stable14706
✅ vite-stable128025
✅ 🪟 Windows
AppPassedFailedSkipped
✅ nextjs-turbopack15300
✅ 📋 Other
AppPassedFailedSkipped
✅ e2e-local-dev-nest-stable128025
✅ e2e-local-dev-tanstack-start-128025
✅ e2e-local-postgres-nest-stable128025
✅ e2e-local-postgres-tanstack-start-128025
✅ e2e-local-prod-nest-stable128025
✅ e2e-local-prod-tanstack-start-128025
✅ e2e-vercel-prod-tanstack-start126027

📋 View full workflow run

@github-actions

github-actionsBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit e3474ec · Fri, 10 Jul 2026 20:22:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioAvg (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstream1409 (+21%)1764 🔴1842 🔴2019 🔴30
TTFShook + stream1601 (+27%)1986 🔴2077 🔴2626 🔴30
STSO1020 steps (1-20)307 (+22%)384 🔴419 🔴488 🔴19
STSO1020 steps (101-120)443 (-9.3%)420 🔴478 🔴1364 🔴19
STSO1020 steps (1001-1020)887 (+2.6%)979 🔴1073 🔴1103 🔴19
WOstream1409 (+21%)17641842201930
WOhook + stream1601 (+27%)19862077262630
SLstream4811 (-2.7%)5547 🔴5774 🔴6062 🔴30
SLhook + stream4749 (-3.5%)5736 🔴5803 🔴6379 🔴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 execution · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (time outside step bodies, client start → last step body exit) · SL: stream latency (first chunk write → visible to the reader)

Scenarios — stream: one step that streams chunks back to the client; no hooks, so the run stays in turbo mode · hook + stream: registers a hook before the same streaming step, which exits turbo mode · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges

🟢/🔴 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

TTFS/WO compare client vs deployment clocks and SL compares the step runner’s clock vs the client’s (NTP-synced in CI). WO ends at the last step body exit, the closest observable proxy for the final step-completion request.

The straight reapply of #2547 awaited chunks.return?.()/reader.cancel()
in decodeFrames' finally block, blocking every early-exit caller
(getEventV4, every replay's list read) on the cancel's completion. On
Vercel's Next.js (turbopack + webpack) Function lanes this hung
indefinitely — confirmed via CI: every single E2E Vercel Prod run got
stuck in "running" after its step completed, for the full 30-minute
window, while all other frameworks passed cleanly in ~15 minutes.
Same failure signature and same two CI lanes as #2807's awaited
reader.cancel() bug in the core abort-stream reader — very likely the
actual (undocumented) reason #2547 was reverted. Fix: fire-and-forget
the cancel, matching #2807's pattern.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

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

Approval notes: I reviewed the stream lifecycle and found no blocker or correctness regression. I verified cleanup on early return, break, and error paths; both the async-iterable and Web ReadableStream reader paths; detached cleanup error handling; unchanged chunk, empty-body, truncation, and end-sentinel decoding; the getEventV4 HTTP path; and the correctly scoped @workflow/world-vercel changeset. Locally, 31 targeted tests, package typecheck, and Biome checks passed. CI unit tests and every local and Vercel E2E lane are green, including Next.js Turbopack and Webpack.

@VaguelySerious
VaguelySerious merged commit 4dce2ae into mainJul 10, 2026
170 of 172 checks passed
@VaguelySerious
VaguelySerious deleted the peter/reapply-cancel-v4-frame-stream branch July 10, 2026 21:50
@github-actionsgithub-actionsBot mentioned this pull request Jul 10, 2026
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.

3 participants

@VaguelySerious@karthikscale3@smaeda-ks