Skip to content

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[world-vercel] Recover from wedged HTTP/2 events connections by VaguelySerious · Pull Request #3370 · vercel/workflow · GitHub
Skip to content

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

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

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

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

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

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

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

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

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

@VaguelySerious@karthikscale3
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); [world-vercel] Recover from wedged HTTP/2 events connections by VaguelySerious · Pull Request #3370 · vercel/workflow · GitHub
Skip to content

[world-vercel] Recover from wedged HTTP/2 events connections - #3370

Merged
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal
Aug 6, 2026
Merged

[world-vercel] Recover from wedged HTTP/2 events connections#3370
VaguelySerious merged 1 commit into
mainfrom
peter/h2-session-selfheal

Conversation

@VaguelySerious

Copy link
Copy Markdown
Member

Problem

A compute instance can end up with an HTTP/2 connection to the events API that stays established but delivers nothing. Every event request multiplexed onto that session then times out, for the rest of the instance's life. Observed in production as UND_ERR_INFO (HTTP/2: "stream timeout after ...") with dnsDuration/tlsDuration/netDuration all zero, repeating on the retry ladder: no new connection is ever attempted.

undici (7.28.0) does not recover from this on its own:

  • On a stream error the H2 path deliberately keeps the session alive: "We do not destroy the socket as we can continue using the session" (lib/dispatcher/client-h2.js). The H1 path destroys the socket, which is why this was self-healing before the events transport moved to H2.
  • keepAliveTimeout is never read on the H2 path, so the agent's 10s idle timeout does not retire the session.
  • headersTimeout is unused on H2 as well; only bodyTimeout arms the stream timer.
  • session.ping() cannot detect it either: Node's callback only fires on ACK, with no timeout of its own.

Better connection reuse in recent versions concentrates traffic onto fewer sessions, so one poisoned session now affects far more requests than it used to.

Changes

Both scoped to the events transport, which is the only H2 agent with multiplexing on.

1. The shared events dispatcher is recycled after repeated transport failures.createDispatcherRecycler owns the singleton. Three consecutive failures (UND_ERR_INFO, headers timeout, body timeout) retire the pool and the next request builds a fresh one. Details worth knowing:

  • The threshold is what separates faults undici already recovers from (H1 socket eviction, H2 GOAWAY/reset: one failure and done) from the wedged-session case, which is unbounded. Routine GOAWAY bursts don't trigger a rebuild.
  • Connect/DNS/TLS/abort errors don't count: a new pool hits the same wall.
  • Outcomes are keyed on dispatcher identity, so a caller-supplied dispatcher is ignored and the tail of a failing batch can't immediately recycle its own replacement.
  • The retired pool is close()d after a delay rather than destroyed, so a request that already resolved it isn't turned into a step retry by ClientClosedError. A destroy() backstop follows in case close() never settles. Both timers are unref'd.
  • instrumentedFetch gained an onTransportOutcome hook to report this. An HTTP error status is not a transport failure: the origin answered.

2. WORKFLOW_H2_MULTIPLEX=0 now disables HTTP/2 for the events path. It previously only skipped the multiplexing interceptor and left allowH2: true with pipelining: 100, which is not a mitigation for an H2 transport fault. Against a black-holed loopback origin, that configuration still failed one request in eight indefinitely; allowH2: false evicted the socket on the first timeout and recovered on the next request.

Testing

http-client.test.ts gained a black-hole harness: a real h2 origin behind a node:net TCP proxy that keeps both sockets open and drops all bytes for one chosen flow. It asserts three things.

  • The failure being fixed: 12 of 12 requests fail, all with a recyclable error code, on a single TCP flow. No recovery.
  • With the recycler: exactly 3 failures, then success from request 4 onward, a different dispatcher instance, and more than one TCP flow.
  • With WORKFLOW_H2_MULTIPLEX=0: the origin sees only HTTP/1.1, failures stay below the request count, and new connections are made.

Plus accounting tests (consecutive-only counting, ignoring dispatchers it no longer owns, which error codes qualify, self-referential cause chains) and an events-v4 test that a v4 request whose fetch rejects the way a wedged session does actually reaches the recycler, so the wiring can't silently regress.

Follow-ups, not in this PR

  • Upstream undici: destroy the H2 session on stream timeout, honor keepAliveTimeout and headersTimeout on the H2 path, and add a client-side PING timeout.
  • The stream write/close agents also run allowH2: true (multiplexing off, since appends aren't idempotent). They could get the same accounting; left out to keep this scoped to the path where the failure was observed.
  • What black-holes the flow in the first place is still open. Leading candidates are a middlebox dropping a NAT/LB mapping across a compute freeze, or an anycast/ECMP rehash. Neither is something the client can prevent, which is why the fix is recovery rather than avoidance.

undici never retires an HTTP/2 session whose stream timed out ("We do not
destroy the socket as we can continue using the session" in client-h2.js),
and it consults neither `keepAliveTimeout` nor `headersTimeout` on the H2
path. When a connection's flow stops delivering while the TCP connection
stays established, every event request multiplexed onto that session times
out for as long as the compute instance lives.
Two changes, both in the events transport:
- The shared events dispatcher is now owned by a recycler. Three consecutive
transport failures (`UND_ERR_INFO`, headers/body timeouts) retire the pool
and the next request builds a fresh one. Connect/DNS/abort errors don't
count: a new pool hits the same wall.
- `WORKFLOW_H2_MULTIPLEX=0` now sets `allowH2: false` instead of only
skipping the multiplexing interceptor, so the kill switch actually takes
the events path off HTTP/2.
Covered by a black-hole harness: an h2 origin behind a TCP proxy that drops
bytes for one flow. It asserts the old behavior (12/12 requests fail, one TCP
flow, no recovery) and the new one (three failures, then a rebuilt pool that
stays healthy).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 130faeb

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/cliPatch
@workflow/corePatch
@workflow/webPatch
workflowPatch
@workflow/world-testingPatch
@workflow/buildersPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/vitestPatch
@workflow/web-sharedPatch
@workflow/astroPatch
@workflow/nestPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
@workflow/vitePatch
@workflow/nuxtPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 130faeb · Thu, 06 Aug 2026 02:04:21 GMT · run logs

Backend: vercel · app: nextjs-turbopack

MetricScenarioBest (ms)P75 (ms)P90 (ms)P99 (ms)Samples
TTFSstep275 (-64%) 💚1343 🔴 (+38%) 🔻1384 🔴 (+31%) 🔻1735 🔴 (+3.5%)30
TTFSstream1276 (+40%) 🔻1335 🔴 (+37%) 🔻1356 🔴 (+36%) 🔻1393 🔴 (+7.6%)30
TTFShook + stream1515 (+74%) 🔻1585 🔴 (+28%) 🔻1604 🔴 (+27%) 🔻1682 🔴 (+13%)30
STSO1020 steps (inline)81 (-10%)123 (-6.1%)142 (-9.0%)216 (-13%)1019
WO1020 steps122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)122893 (-5.3%)1
SLstream latency81 (±0%)110 🔴 (-11%)128 🔴 (-15%)437 🔴 (+6.3%)30
SOstream overhead (text)105 (+2.9%)158 (+0.6%)198 (+8.8%)278 (+40%) 🔻30
SOstream overhead (structured)95 (-7.8%)148 (-9.8%)170 (-4.5%)254 (-20%) 💚30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 128734ms → this run 121569ms (Δ -7165ms, -6%)

 50-100 ms █┃ main 4 this 56 +52
100-150 ms ███████████████████████┃ main 896 this 891 -5
150-200 ms ┃█ main 93 this 55 -38
200-250 ms ┃ main 16 this 12 -4
250-300 ms ┃ main 3 this 3 +0
300-350 ms ┃ main 4 this 1 -3
350-400 ms ┃ main 1 this 0 -1
400-450 ms ┃ main 1 this 1 +0
500-550 ms ┃ main 1 this 0 -1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@github-actions

github-actionsBot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

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

✅ ▲ Vercel Production

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

✅ 💻 Local Development

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

✅ 📦 Local Production

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

✅ 🐘 Local Postgres

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

✅ 🪟 Windows

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

✅ 📋 Other

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

✅ vercel-multi-region

AppPassedFailedSkipped
✅ nextjs-turbopack2700

📋 View full workflow run

@VaguelySerious
VaguelySerious marked this pull request as ready for review August 6, 2026 16:08
@VaguelySerious
VaguelySerious requested review from a team, fantix and msullivan as code ownersAugust 6, 2026 16:08

@karthikscale3karthikscale3 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Approving. The implementation is well designed and the black-hole reproduction provides strong coverage of the observed production failure. The two inline notes are non-blocking follow-ups.

@@ -455,6 +467,7 @@ export async function instrumentedFetch(
throw error;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking caveat — this reports success as soon as fetch() returns a Response, which is normally after headers arrive and before the body is consumed. The V4 paths later consume response.arrayBuffer() or stream response.body, so a connection that black-holes mid-body can raise UND_ERR_BODY_TIMEOUT after this success has already reset the recycler, and that later error never reaches onTransportOutcome. The fix is correct for the observed no-response failure, but the advertised body-timeout coverage is incomplete; consider reporting the outcome after body consumption or narrowing that guarantee.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

// Re-resolved per request, exactly as fetchV4 does — caching the
// dispatcher across requests would pin the caller to the retired pool.
const dispatcher = recycler.get();
const error = await attempt(dispatcher);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review: Non-blocking test suggestion — this excellent black-hole test drives requests sequentially. In production, multiplexed requests can complete concurrently; successes from a healthy connection may interleave with failures from a poisoned one and continually reset the consecutive-failure counter. A concurrent/interleaved test would document whether that degraded multi-connection case is intentionally tolerated or should also trigger recycling.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Ack - not doing here

@VaguelySerious
VaguelySerious merged commit bf4dda6 into mainAug 6, 2026
290 of 294 checks passed
@VaguelySerious
VaguelySerious deleted the peter/h2-session-selfheal branch August 6, 2026 18:45
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.

2 participants

@VaguelySerious@karthikscale3