Skip to content

http2: give Http2Stream its own per-stream idle timer#30308

Open
robobun wants to merge 2 commits into
mainfrom
farm/6e45c4d0/http2-per-stream-timer
Open

http2: give Http2Stream its own per-stream idle timer#30308
robobun wants to merge 2 commits into
mainfrom
farm/6e45c4d0/http2-per-stream-timer

Conversation

@robobun

@robobun robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30307

Problem

@fastify/http-proxy with HTTP/2 reports FST_REPLY_FROM_HTTP2_REQUEST_TIMEOUT warnings on every idle gap ≥ 5 s, even though the proxied response is 200 OK. @fastify/reply-from attaches a req.setTimeout(requestTimeout, cb) on every outgoing Http2Stream. On Bun that path was broken two ways.

Cause

Http2Stream.prototype.setTimeout delegated to session.setTimeout, which delegated to the underlying socket's setTimeout. Consequences:

  1. Each req.setTimeout(ms, cb) registered cb as a once('timeout') listener on the shared socket. The callbacks accumulated across requests — a single socket idle fire ran every still-pending per-stream callback at once, including callbacks for streams that had already ended. That's the 4-at-once burst the reporter saw after the first 5 s gap.
  2. ServerHttp2Session.#onTimeout and ClientHttp2Session.#onTimeout additionally called parser.forEachStream(emitTimeout), broadcasting 'timeout' to every stream the parser still tracked. Per Node.js, a session-level idle timeout emits 'timeout' on the session only — streams manage their own timers.

Fix

Give Http2Stream a real per-stream idle timer keyed on kTimeout from internal/timers:

  • setTimeout(ms, cb) installs a per-instance setTimeout(...).unref() that fires _onTimeoutemit('timeout'). Mirrors Socket.prototype.setTimeout validation (getTimerDuration, validateFunction, returns this).
  • _unrefTimer() refreshes it; called on stream read (pushToStream) and stream write (_write, _writev) so an active stream never times out.
  • _destroy clears the timer so a stream that already closed never fires.

Drop the parser.forEachStream(emitTimeout) cascade from both session #onTimeout handlers — session idle timeouts now emit on the session only, matching Node.

Verification

Repro from the issue on the debug build — silent, all 200 OK:

Burst (warm up + create H2 session):
  #1             200 in 1046ms
  #2             200 in 146ms
  #3             200 in 108ms
Sleep 1s, then GET:
  after 1s       200 in 100ms
Sleep 5s, then GET:
  after 5s       200 in 106ms
Sleep 15s, then GET:
  after 15s      200 in 94ms
Sleep 30s, then GET:
  after 30s      200 in 91ms
Sleep 60s, then GET:
  after 60s      200 in 145ms

All 166 Node test-http2-*.js parallel tests pass; the 5 timeout-specific ones (test-http2-timeouts, test-http2-session-timeout, test-http2-compat-server{request,response}-settimeout, test-http2-server-close-idle-connection) stay green.

Two new tests in test/js/node/http2/node-http2.test.js:

  • does not fire on completed streams after a session-idle gap — exact shape of the bug report (fails on master with req-1..req-4 firing, passes on this branch)
  • session-level setTimeout does not cascade 'timeout' to tracked streams

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jun 17th, 2026

@robobun, your commit 84d6bb5 has 3 failures in Build #63282 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30308

That installs a local version of the PR into your bun-30308 executable, so you can run:

bun-30308 --bun

@github-actions github-actions Bot added the claude label May 6, 2026
@github-actions

github-actions Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun test crash in mocked http2 with no-op timeout #19049 - PR adds proper per-stream timer lifecycle (_onTimeout, _unrefTimer, cleanup in _destroy), which may fix the crash triggered by a dangling timeout callback on a mocked/destroyed http2 stream

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #19049

🤖 Generated with Claude Code

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

find-issues-bot flagged #19049 as potentially fixed by this PR — it isn't. That issue is a JSC GC-phase segfault when the entire node:http2 module is mock.module-replaced and the test awaits a never-resolving promise; it's about VM teardown with live JS-side Timeouts, not about the Http2Stream timer lifecycle this PR rewrites. The repro from that issue already exits cleanly on Bun 1.3.13, so it was resolved elsewhere well before this branch.

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 357b4090-bd67-422d-b7f8-87425ed16a50

📥 Commits

Reviewing files that changed from the base of the PR and between d055e33 and fc742c1.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/regression/issue/30307.test.ts

Walkthrough

Adds per-session and per-stream idle timer management to HTTP/2: imports timer utilities, implements per-stream timer lifecycle and refresh points in data paths, adjusts session timeout emission to avoid broadcasting to streams, and adds regression tests covering stream vs session timeout behavior.

Changes

HTTP/2 Idle Timer Support

Layer / File(s) Summary
Timer imports
src/js/node/http2.ts
Imports kTimeout and getTimerDuration from internal/timers to normalize durations and store per-stream timeout state.
Per-stream API / lifecycle
src/js/node/http2.ts
Adds/updates Http2Stream.setTimeout() to normalize and (re)install an unref'd timer, defines _onTimeout() to emit "timeout", adds _unrefTimer() to refresh the timer, and clears the timer in _destroy().
Data-path timer refreshes
src/js/node/http2.ts
Calls this._unrefTimer() in data delivery paths (pushToStream, _writev, _write) to refresh per-stream idle timers during active transfers.
Session timeout semantics
src/js/node/http2.ts
Adjusts #onTimeout handlers in ServerHttp2Session and ClientHttp2Session to emit session-level timeouts without broadcasting or forcing per-stream timeouts.
Tests
test/regression/issue/30307.test.ts
Adds two regression tests: one ensuring completed streams do not get per-stream timeouts after a session-idle gap; another ensuring session-level timeouts do not emit 'timeout' on live streams.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: giving Http2Stream its own per-stream idle timer, which is the core fix for the HTTP/2 timeout issues reported.
Description check ✅ Passed The PR description comprehensively covers the problem, cause, and fix with detailed verification steps, matching the template structure with clear problem/cause/fix sections.
Linked Issues check ✅ Passed The PR successfully addresses issue #30307 by implementing per-stream idle timers and removing session-level timeout cascading, directly fixing the spurious FST_REPLY_FROM_HTTP2_REQUEST_TIMEOUT warnings.
Out of Scope Changes check ✅ Passed All changes in src/js/node/http2.ts and the new test file are scoped to implementing per-stream timers and fixing timeout behavior, directly aligned with issue #30307.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 test/js/node/http2/node-http2.test.js:2088-2095 — These tests rely on hardcoded sleeps (setTimeout(r, 500) / setTimeout(r, 400)) and tight timer thresholds (300ms / 150ms), which test/CLAUDE.md prohibits ("never wait for time to pass in tests"). The second test in particular can deterministically await once(client, 'timeout') instead of sleeping 400ms and then asserting sessionFired.v === true; for the first test, consider widening the 300ms margin so a loaded ASAN runner can't legitimately fire the per-stream timer before the first data chunk refreshes it.

    Extended reasoning...

    What this is

    test/CLAUDE.md:21 states: "Do not write flaky tests. Unless explicitly asked, never wait for time to pass in tests. Always wait for the condition to be met instead." Both new tests in the Http2Stream.setTimeout per-stream idle timer describe block use hardcoded await new Promise(r => setTimeout(r, ...)) sleeps paired with short timer thresholds, rather than awaiting a deterministic condition.

    How it can manifest

    Test 2 (session-level setTimeout does not cascade 'timeout' to tracked streams) arms client.setTimeout(150), sleeps 400ms, then asserts sessionFired.v === true. This is the avoidable case: the test is waiting for a positive event (the session 'timeout') by sleeping past it. If the event loop stalls and the socket-idle timer hasn't fired within the 400ms window, sessionFired.v is still false and the test fails spuriously.

    Test 1 (does not fire on completed streams after a session-idle gap) arms req.setTimeout(300, ...) on each request and later sleeps 500ms. The 500ms sleep itself is defensible — proving a negative ("the timer does not fire after the stream ends") inherently requires waiting past the armed duration. The tighter risk is the 300ms threshold: between req.setTimeout(300, ...) and the first pushToStream call that refreshes the timer via _unrefTimer(), a heavily-loaded ASAN CI runner could plausibly stall >300ms even on a warmed loopback connection, causing the per-stream timer to fire legitimately during the request and producing a false-positive timeoutFires entry.

    Why existing code doesn't prevent it

    The file defines ASAN_MULTIPLIER = isASAN ? 3 : 1, but it's only applied to one Jest test timeout (line 1583) — the new 300ms / 150ms / 500ms / 400ms constants are not scaled by it. There is no event-based synchronization for the session-timeout assertion in test 2.

    Step-by-step example (test 2 on a slow runner)

    1. client.setTimeout(150) arms the socket idle timer.
    2. await new Promise(r => setTimeout(r, 400)) is scheduled.
    3. Under heavy load, the JS timer queue is delayed; the 400ms sleep resolves at, say, T+410ms wall-clock, but the socket's idle-timeout callback (which goes through libuv → #onTimeoutemit('timeout')) hasn't run yet because the loop was blocked.
    4. expect(sessionFired.v).toBe(true) fails even though the implementation is correct.

    The deterministic alternative — await new Promise(r => client.once('timeout', r)) followed by expect(streamFired).toEqual([]) — has no such race and also makes the sessionFired flag unnecessary.

    Impact

    Potential CI flakiness, particularly under ASAN or on contended runners. Not a production-code bug.

    Suggested fix

    • Test 2: replace the 400ms sleep + sessionFired.v flag with await new Promise(r => client.once('timeout', r)), then assert streamFired is empty. This removes the race entirely.
    • Test 1: the sleep is structurally necessary for a negative assertion, but widen the per-stream threshold (e.g. req.setTimeout(1000, ...) with a ~1500ms idle) or scale by ASAN_MULTIPLIER so a transient stall before the first data chunk can't trip it.

Comment thread src/js/node/http2.ts Outdated

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/regression/issue/30307.test.ts`:
- Line 75: Replace the fixed 500ms sleeps in the test (the `await new Promise(r
=> setTimeout(r, 500));` occurrences) with awaiting the actual session timeout
event: locate the two occurrences in test/regression/issue/30307.test.ts and
change them to await the session/process/page timeout event (e.g. use
events.once(sessionOrPage, 'timeout') or sessionOrPage.waitForEvent('timeout')
depending on the test harness) so the test waits for the 'timeout' event itself
rather than sleeping.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6365787a-6714-4aa1-b33a-78b8bf0f0048

📥 Commits

Reviewing files that changed from the base of the PR and between a97b970 and d055e33.

📒 Files selected for processing (1)
  • test/regression/issue/30307.test.ts

Comment thread test/regression/issue/30307.test.ts Outdated
Comment thread src/js/node/http2.ts Outdated
@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI flake summary across the three retriggers — all hit pre-existing Windows/ASAN flakes on code paths this PR does not touch:

  • #51993 (fc742c1)debian-13 x64-asan: ASSERTION FAILED: wasRemoved in WTF::AtomStringImpl::remove and ThreadLock panic, both inside test/js/web/fetch/fetch-http2-client.test.ts. Exercises src/http/h2_client/ (experimental fetch({ grpc/protocol:http2 })), not src/js/node/http2.ts. Same signature in builds 51892/51888/51883 on unrelated PRs.

  • #52013 (5dd32a7)windows 2019 x64-baseline: test/js/bun/spawn/spawn-ipc-gc.test.tsExpected: >= 7, Received: 6 in the Bun.spawn IPC-GC collectability test added by fix(spawn): allow IPC subprocess to be garbage collected after disconnect #30051. Windows-specific GC-tolerance flake.

  • #52001 (ac176e5) and #52023 (48555c8)windows 2019 x64-baseline: test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts timed out 4× on Windows. node:http request close-on-abort flake. Same 4× timeout recurs on build 51925 (PR 425d2e2) and others.

Windows test-bun is at ~5% job failure rate across the last 40 builds; this PR has hit it twice on the same test.

The actual test-proof for this PR is reproducible locally and clean:

bun bd test test/regression/issue/30307.test.ts        → 2 pass, 0 fail (with fix)
git stash; bun bd test ... ; git stash pop             → 1 pass, 1 fail (without fix)

All 58 tests in fetch-http2-client.test.ts pass on this branch locally. All 166 test-http2-*.js Node compat tests pass.

Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
Comment thread src/js/node/http2.ts
@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

Another unrelated Windows flake on retrigger #52033 (046d9e4):

  • windows 2019 x64-baselinetest/js/bun/jsc-stress/jsc-stress.test.ts > Wasm (BBQ/OMG) > ipint-bbq-osr-with-try3.js: stderr: mprotect failed: 487 (Windows VirtualProtect → JIT memory allocation). JSC/WebKit JIT on Wasm; unrelated to src/js/node/http2.ts. Retried 4× in-shard.

Tally of flakes on this PR so far (5 builds, 0 signals related to http2.ts):

  • #51993 → wasRemoved / ThreadLock in fetch-http2-client (ASAN JSC)
  • #52001 → Windows test-http-should-emit-close-when-connection-is-aborted (node:http)
  • #52013 → Windows spawn-ipc-gc GC tolerance (Bun.spawn IPC)
  • #52023 → Windows test-http-should-emit-close-when-connection-is-aborted (node:http)
  • #52033 → Windows jsc-stress Wasm mprotect failed: 487 (JSC JIT)

@robobun

robobun commented May 6, 2026

Copy link
Copy Markdown
Collaborator Author

Build #52040 hit two more Windows flakes (different jobs, same build):

  • windows 2019 x64test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts timed out again (same flake as #52001 and #52023)
  • windows 11 aarch64test/cli/hot/hot.test.ts retried

6 consecutive CI failures on this PR, zero related to src/js/node/http2.ts:

Build Shard Failing test Subsystem
#51993 debian-13 x64-asan fetch-http2-client src/http/h2_client
#52001 windows x64-baseline http-should-emit-close node:http
#52013 windows x64-baseline spawn-ipc-gc Bun.spawn IPC
#52023 windows x64-baseline http-should-emit-close node:http
#52033 windows x64-baseline jsc-stress ipint-bbq Wasm JSC JIT
#52040 windows x64 + aarch64 http-should-emit-close + hot.test.ts node:http + bun:hot

Not planning further retriggers — will leave it for maintainer review. The actual PR content verifies cleanly locally:

bun bd test test/regression/issue/30307.test.ts        → 2 pass
bun bd test test/js/node/http2/node-http2.test.js      → 243 pass (4 pre-existing fails on master, unrelated)
166 Node compat test-http2-*.js                        → all pass
58 fetch-http2-client.test.ts                          → all pass

Test-proof: git stash src/; bun bd test 30307.test.ts → fails (1 pass, 1 fail). git stash pop; bun bd test 30307.test.ts → passes (2 pass, 0 fail).

@timothyliu

Copy link
Copy Markdown

Pleasae help to review!

@robobun robobun force-pushed the farm/6e45c4d0/http2-per-stream-timer branch from 9bc7582 to bfbdb61 Compare June 18, 2026 03:40
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main past the node:http2 rewrite (#31584). That PR reworked src/js/node/http2.ts (~1900 lines), so the old commits conflicted throughout; I reset to origin/main and re-applied the fix as a single clean commit on the new structure.

The bug is still present after the rewrite, just reshaped:

  • Http2Stream.setTimeout(ms, cb) still delegated to session.setTimeout, which now registers cb as a once('timeout') listener on the session (previously on the socket). Callbacks still accumulate across requests and all fire on one socket-idle timeout.
  • Both ServerHttp2Session.#onTimeout and ClientHttp2Session.#onTimeout still broadcast via parser.forEachStream(emitTimeout).

Re-applied on the new code:

  • Http2Stream.setTimeout now installs a per-instance setTimeout(...).unref() (kTimeout from internal/timers), sets this.timeout = msecs, validates, and returns this.
  • _unrefTimer() refresh points: pushToStream (inbound DATA), _write/_writev (outbound DATA), and both streamHeaders handlers (HEADERS receipt) — the last two fold in the review feedback on this PR.
  • _destroy clears the timer.
  • Dropped the forEachStream(emitTimeout) cascade from both #onTimeout handlers; emitTimeout helper removed.

Verification on the rebased branch:

bun bd test test/regression/issue/30307.test.ts   → 2 pass, 0 fail (with fix)
git stash push -- src/ ; bun bd test …            → 2 fail  (without fix)
git stash pop ; bun bd test …                     → 2 pass

bun bd test test/js/node/http2/node-http2.test.js → 287 pass, 0 fail
5 timeout-focused test-http2-*.js Node compat     → all pass

The rest of the test-http2-*.js failures in a raw sweep are the rewrite's own "not yet passing" set (registered in test/expectations.txt); I confirmed each one fails identically on clean origin/main without this diff.

@robobun robobun force-pushed the farm/6e45c4d0/http2-per-stream-timer branch from bfbdb61 to 9eb928d Compare June 18, 2026 03:44
Comment thread src/js/node/http2.ts
Comment thread test/regression/issue/30307.test.ts Outdated
Comment thread test/regression/issue/30307.test.ts Outdated
@robobun robobun force-pushed the farm/6e45c4d0/http2-per-stream-timer branch from 9eb928d to 936851e Compare June 18, 2026 04:13
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts
@robobun robobun force-pushed the farm/6e45c4d0/http2-per-stream-timer branch from 936851e to f86ec37 Compare June 18, 2026 04:41
Comment thread src/js/node/http2.ts
req.setTimeout(ms, cb) on an Http2Stream delegated to
session.setTimeout, which registers the callback on the session and
arms the underlying socket's idle timer. Two problems fell out of that:

1. Each per-stream setTimeout call added its callback as a
   once('timeout') listener on the shared session. The callbacks
   accumulated across requests, so a single socket idle timeout fired
   every still-pending per-stream callback at once, even for streams
   that had already ended.
2. Both ServerHttp2Session and ClientHttp2Session #onTimeout handlers
   broadcast 'timeout' to every stream still tracked by the parser via
   forEachStream(emitTimeout), contrary to Node.js semantics where a
   session-level idle timeout emits on the session only.

@fastify/reply-from attaches a per-Http2Stream requestTimeout, so on
Bun the warn path fired on every completed/live stream after an idle
gap >= 5s, producing FST_REPLY_FROM_HTTP2_REQUEST_TIMEOUT bursts in the
proxy log. The proxied response was still 200 OK because the retry
path succeeded, but the log flooded.

Give Http2Stream a real per-stream timer keyed off kTimeout from
internal/timers:

- setTimeout(ms, cb) installs a per-instance setTimeout.unref() that
  fires _onTimeout -> emit('timeout'), mirroring Socket.prototype
  .setTimeout (sets this.timeout, validates, returns this).
- _unrefTimer() refreshes it; called on stream read (pushToStream),
  stream write (_write/_writev), and HEADERS receipt (streamHeaders) so
  an active stream never times out.
- _destroy clears the timer so a stream that has already closed never
  fires its callback.

Drop the parser.forEachStream(emitTimeout) cascade from both #onTimeout
handlers: session-level socket idle timeouts now emit 'timeout' on the
session only, matching Node.

Fixes #30307
@robobun robobun force-pushed the farm/6e45c4d0/http2-per-stream-timer branch from f86ec37 to e518b49 Compare June 18, 2026 05:04

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

All prior review feedback has been addressed and I found nothing new this round — but this rewires Http2Stream timer semantics across ~10 lifecycle touch points in node:http2 (freshly rebased onto the #31584 rewrite), so it's worth a maintainer sign-off.

Extended reasoning...

Overview

This PR replaces Http2Stream.prototype.setTimeout's delegation to the session/socket timer with a real per-stream idle timer (kTimeout from internal/timers), adds _onTimeout/_unrefTimer, wires refresh points into pushToStream, _write/_writev, both streamHeaders handlers, respond(), pushStream(), and additionalHeaders(), disarms the timer in markStreamClosed() (covering close(), _destroy, and the deferred-destroy END_STREAM path), and drops the parser.forEachStream(emitTimeout) cascade from both session #onTimeout handlers. Three regression tests cover the accumulated-callback bug, the session→stream cascade, and the deferred-destroy buffered-body path.

Security risks

None identified. This is timer-lifecycle bookkeeping in a Node-compat module; no auth, crypto, parsing, or untrusted-input handling is touched. The new timer is .unref()'d so it cannot pin the event loop.

Level of scrutiny

Moderate-to-high. src/js/node/http2.ts is production-critical Node-compat code, and this is a behavioral change (not a mechanical fix) to per-stream timeout semantics that real frameworks (@fastify/reply-from) depend on. The change is well-scoped and now mirrors Node's setStreamTimeout/kUpdateTimer/closeStream placement closely, but the file was just rewritten in #31584 and this PR was rebased onto that — so the surrounding code is fresh and the interaction surface is non-trivial.

Other factors

This PR has been through five rounds of inline review on my end; every issue raised (timer not cleared in close(), missing HEADERS refresh points inbound and outbound, markStreamClosed consolidation for the END_STREAM deferred-destroy path, additionalHeaders() sibling, class-body field declarations, this.timeout introspection, test conventions) has been addressed and the threads are resolved. The bug-hunting system found nothing new on the current revision. The author reports all 166 test-http2-*.js Node compat tests and the timeout-specific subset pass, and the regression tests fail on master / pass on this branch. CI failures over the PR's history were unrelated Windows/ASAN flakes. Given the scope and the recent rebase onto a large rewrite, a human maintainer should give the final OK.

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

All prior review feedback has been addressed and I found nothing further — the timer lifecycle now covers every close transition and every HEADERS/DATA activity point I could identify. That said, this reworks Http2Stream timeout semantics across ~10 call sites in a core node:http2 path, so it's worth a maintainer's final look rather than a bot sign-off.

Extended reasoning...

Overview

This PR replaces Http2Stream.setTimeout's delegation to the session/socket timer with a real per-stream idle timer (kTimeout), adds _unrefTimer() refresh points at every inbound/outbound HEADERS and DATA site (pushToStream, _write/_writev, both streamHeaders handlers, respond(), pushStream(), additionalHeaders()), disarms the timer in markStreamClosed() so every close transition (explicit RST, natural END_STREAM with deferred destroy, _destroy) clears it, and removes the session→stream forEachStream(emitTimeout) cascade from both #onTimeout handlers. Three regression tests in test/regression/issue/30307.test.ts cover the accumulated-callback bug, the session-cascade bug, and the deferred-destroy END_STREAM path.

Security risks

None identified. This is timer lifecycle management in a Node.js-compat module; no auth, crypto, permissions, or untrusted-input parsing is touched. The change strictly narrows when 'timeout' fires (from session-wide broadcast to per-stream idle), which if anything reduces the surface for unexpected callback invocation.

Level of scrutiny

Moderate-high. src/js/node/http2.ts is a production-critical Node.js compat surface used by real frameworks (@fastify/http-proxy is the reporter). The change is not mechanical: it required five review rounds to converge on correct timer-clear placement (close()markStreamClosed()), complete refresh-point coverage (inbound HEADERS, then outbound HEADERS, then additionalHeaders()), and class-body field declarations for shape monomorphism. Each round surfaced a real edge case. The final diff looks correct and closely mirrors Node's setStreamTimeout/kUpdateTimer/closeStream placement, but the iteration history itself is evidence that this area is subtle enough to warrant a maintainer's sign-off rather than auto-approval.

Other factors

  • All 11 of my prior inline comments are resolved and reflected in the current diff.
  • The bug-hunting system found nothing on this revision.
  • Author reports 166 Node test-http2-*.js compat tests and the full node-http2.test.js suite pass; the regression test fails on master and passes on the branch.
  • CI failures across the PR's history were documented as unrelated Windows/ASAN flakes in other subsystems.
  • No CODEOWNERS entry covers src/js/node/http2.ts.
  • A community member explicitly requested maintainer review on 2026-05-18; the PR was rebased onto the post-#31584 node:http2 rewrite on 2026-06-18 and re-verified.

@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is green; the two CI failures since the rebase are unrelated darwin-agent flakes, each hitting a different test:

Build sha darwin lane failing test subsystem
#63281 e518b49 26 aarch64 test/js/bun/typescript/type-export.test.ts harness posix_spawn ENOENT — bun-profile binary missing on agent
#63282 84d6bb5 (re-roll) 14 aarch64 test-http-exceptions.js (timeout) + test-tls-client-destroy-soon.js (big.length === bytesRead byte-count race) node:http / node:tls

None of these touch src/js/node/http2.ts. test-tls-client-destroy-soon.js has zero references to http2 and isn't in test/expectations.txt; it's the timing-sensitive destroy-soon read race. The failures differ run to run on different darwin agents, which is the signature of environmental flakiness, not a regression from this diff (a real regression would fail the same http2 test deterministically).

I've used my one CI re-roll (84d6bb5) and won't keep pushing ci: retrigger. Local verification on the rebased branch is clean:

bun bd test test/regression/issue/30307.test.ts   -> 3 pass (test #3 fails on the prior commit)
bun bd test test/js/node/http2/node-http2.test.js -> 287 pass, 0 fail
5 timeout-focused test-http2-*.js compat tests    -> pass

All 12 review threads are resolved. This needs a maintainer to merge (or re-run the darwin lanes); the remaining red is unrelated darwin flake.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@fastify/http-proxy with HTTP/2 spuriously emits FST_REPLY_FROM_HTTP2_REQUEST_TIMEOUT after idle on Bun

2 participants