Skip to content

http_parser: multiply ConnectionsList#expired() timeouts in 64 bits - #46411

Open
robobun wants to merge 1 commit into
mainfrom
robobun/0554d1ed/connectionslist-expired-64bit-timeouts
Open

robobun wants to merge 1 commit into
mainfrom
robobun/0554d1ed/connectionslist-expired-64bit-timeouts

Conversation

@robobun

@robobun robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • ConnectionsList#expired() of process.binding("http_parser") wraps a timeout above 4294 ms. expired(4295, 0) reports a 3 ms old parser. Node v26.3.0 reports none.
  • The cause is the 32-bit product toUInt32(globalObject) * 1000000 at src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp:93 and :95. 4295 ms becomes 32,704 ns.
  • No user reported it (node:http: enforce headersTimeout and requestTimeout on HTTP/1 fallback connections #37739 found it), and it is not a crash. Its only callers are user code: nothing in src/js calls expired(), and node:http timeouts do not use this list.

Fix

  • Cast each toUInt32() result to uint64_t before the multiplication, as Node does.
  • Verified: test/js/node/http/node-http-parser.test.ts, 7 new cases with the results of Node v26.3.0. Five fail on bun 1.4.3 and on a debug build of main. Also ran test-http-parser*.js.
  • Self-reviewed: 15 concerns raised, 14 addressed. Rejected: asking maintainers whether to keep ConnectionsList. The fix does not depend on it.

Background

Downsides

  • None found. Release code: two 32-bit imul become 64-bit, and the text grows from 4055 to 4062 bytes. Compatibility: before bun 1.4.0 the call did not return, so only 1.4.0 to 1.4.3 wrap.
Notes

Reproduction. The script runs on bun and on Node with no flags.

const { HTTPParser } = require("node:_http_common");
const longTimeout = 4_227_858_433; // 48.9 days

function run(ConnectionsList) {
  for (const [headersTimeout, requestTimeout] of [
    [1, 0],
    [0, 1],
    [longTimeout, 0],
    [0, longTimeout],
    [longTimeout, longTimeout],
    [longTimeout, 1],
    [1, longTimeout],
    [4294, 0],
    [4295, 0],
  ]) {
    const list = new ConnectionsList();
    const incomplete = new HTTPParser();
    incomplete.initialize(HTTPParser.REQUEST, {}, 0, 0, list);
    const complete = new HTTPParser();
    complete.initialize(HTTPParser.REQUEST, {}, 0, 0, list);
    complete.execute(Buffer.from("POST / HTTP/1.1\r\nContent-Length: 1\r\n\r\n"));
    const names = new Map([[incomplete, "incomplete"], [complete, "complete"]]);
    const start = performance.now();
    while (performance.now() - start < 3) {}
    const expired = list.expired(headersTimeout, requestTimeout).map(parser => names.get(parser));
    console.log(`expired(${headersTimeout}, ${requestTimeout})`, JSON.stringify(expired));
  }
}

if (process.versions.bun) {
  run(process.binding("http_parser").ConnectionsList);
} else {
  // process.binding("http_parser") throws on Node v26.3.0. A listening server holds a ConnectionsList.
  const server = require("node:http").createServer().listen(0, () => {
    const symbol = Object.getOwnPropertySymbols(server).find(s => s.description === "http.server.connections");
    run(server[symbol].constructor);
    server.close();
  });
}

Each list holds two parsers that are 3 ms old: one with incomplete headers, one with complete headers and a pending body.

Call Node v26.3.0, this branch bun 1.4.0, bun 1.4.3
expired(1, 0) ["incomplete"] ["incomplete"]
expired(0, 1) ["incomplete","complete"] ["incomplete","complete"]
expired(4227858433, 0) [] ["incomplete"]
expired(0, 4227858433) [] ["incomplete","complete"]
expired(4227858433, 4227858433) [] ["incomplete","complete"]
expired(4227858433, 1) ["incomplete"] ["incomplete","complete"]
expired(1, 4227858433) ["incomplete"] ["incomplete","complete"]
expired(4294, 0) [] []
expired(4295, 0) [] ["incomplete"]

The first seven rows are the cases of the test. "This branch" is a debug build.

Wrapped values

Timeout Nanoseconds Value after the 32-bit wrap
4294 ms 4,294,000,000 4,294,000,000 ns (no wrap)
4295 ms 4,295,000,000 32,704 ns
60,000 ms 60,000,000,000 4,165,425,152 ns (4.17 s)
300,000 ms 300,000,000,000 3,647,256,576 ns (3.65 s)
4,227,858,433 ms (48.9 days) 4,227,858,433,000,000 1,000,000 ns (1 ms)

60,000 ms and 300,000 ms are the defaults of headersTimeout and requestTimeout in node:http. With expired(60000, 300000), bun 1.4.3 reports a parser with incomplete headers after 3.65 s and a parser with complete headers after 4.17 s. This branch reports neither in 6 s.

The test

  • The test uses 4,227,858,433 ms and not 4295 ms. With 4295 ms, a test process that stalls for 4.3 s fails on the fixed build. A timeout of 48.9 days has no such limit. The unfixed build still fails, because the value wraps to 1 ms and the parsers are 2 ms old.
  • expired(4227858433, 0) needs the first cast and expired(0, 4227858433) needs the second. With only the first cast, the rows (0, long), (long, long) and (1, long) fail. With only the second cast, the rows (long, 0), (long, long) and (long, 1) fail. I built both variants.
  • On a host with an uptime below 48.9 days, uv_hrtime() is below the timeout. expired() then uses no deadline for that timeout, as Node does. On a host with a longer uptime, the deadline is 48.9 days in the past. The expected result is the same.
  • The rows (1, 0) and (0, 1) are controls. They pass without the fix.
  • The test in node:http: enforce headersTimeout and requestTimeout on HTTP/1 fallback connections #37739 used expired(4295, 4295) and expired(60_000, 300_000). The second passes without the fix for a young parser, because the wrapped values are 4.17 s and 3.65 s.

Reach

  • bun: process.binding("http_parser") returns the class. No built-in module passes a list to parser.initialize() or calls expired(). checkConnections in src/js/node/_http_server.ts calls isRequestTimedOut (src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp), which compares milliseconds in 64 bits with no product. node:http, node:http2: give emit('connection') and allowHTTP1 connections the server's timers and error path #46185 rewrites that sweep.
  • No issue in this repository names ConnectionsList.
  • Before bun 1.4.0 a call to expired() with a nonzero timeout did not return. I ran 1.2.16, 1.3.7 and 1.3.14: the call hangs. On 1.3.14 it also hangs for an empty list. Hardening: input validation and bounds tightening across 26 subsystems #31129 repaired the loop, first released in 1.4.0.
  • Node v26.3.0: process.binding("http_parser") throws No such module: http_parser. A listening http.Server holds a ConnectionsList at Symbol(http.server.connections) (lib/_http_server.js:613), and the sweep of Node calls expired(this.headersTimeout, this.requestTimeout) on it (:746). The http.Server of bun has no such symbol.
  • Node 18 to 23: in the source, process.binding("http_parser") returns the binding with the warning DEP0111 (lib/internal/bootstrap/realm.js). Node v24.0.0 removes http_parser from that list.

Other sites

What this PR does not take from #37739

Verification

  • bun 1.4.3, bun test test/js/node/http/node-http-parser.test.ts -t ConnectionsList: 6 pass, 5 fail.
  • Debug build of main with the test added: the same 5 cases fail.
  • Debug build of this branch: 33 pass, 0 fail in the file. The 7 cases pass 700 of 700 runs with --rerun-each 100. They also pass with BUN_JSC_validateExceptionChecks=1.
  • Debug build of this branch, exit code 0: the 10 test-http-parser*.js files and test-http-server-connections-checking-leak.js in test/js/node/test/parallel/. Also the 3 process.binding tests of test/js/node/process/process.test.js.

Size measurement

  • I compiled JSConnectionsListPrototype.cpp alone from main and from this branch, with the release flags (-O3) and without -flto=thin. size reports 4055 and 4062 bytes of text.
  • llvm-objdump shows the change: imul ebx, ebx, 0xf4240 becomes imul rbx, rax, 0xf4240, and the same for the second timeout.

expired(headersTimeout, requestTimeout) takes milliseconds and compares
uv_hrtime() nanoseconds. The product toUInt32() * 1000000 was a 32-bit
product, so a timeout above 4294 ms wrapped: 4295 ms became 32,704 ns.

Cast each value to uint64_t before the multiplication, as
ConnectionsList::Expired does in Node (src/node_http_parser.cc).
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: e3a6fb3a-aded-4dad-8a34-28cfda5b78a6
📥 Commits

Reviewing files that changed from the base of the PR and between 0793250 and aece17b.

📒 Files selected for processing: 2
  • src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp
  • test/js/node/http/node-http-parser.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:07 PM PT - Oct 21st, 2026

❌ @robobun, your commit aece17b has 1 failures in Build #130453 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46411

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

bun-46411 --bun

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.0 and 1.4.3: expired(4295, 0) reports a parser that is 3 ms old. Node v26.3.0 and this branch report none. The script is in the Notes of the description.
  • The fix and the test are in aece17b.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM — the two-line widening in jsConnectionsList_expired is correct and matches Node's uint64_t cast; the inline note on the test's unbounded spin is a robustness nit, not a blocker.

Extended reasoning...

The change casts each toUInt32 result to uint64_t before the ms-to-ns multiplication at src/jsc/bindings/node/http/JSConnectionsListPrototype.cpp:94 and :96, so timeouts above 4294 ms no longer wrap; the surrounding swap/deadline logic is unchanged and the RETURN_IF_EXCEPTION checks remain after each coercion. A grep of src/jsc/bindings for other * 1000000 or toUInt32(globalObject) * products found no sibling sites sharing the pattern. The test adds a test.each matrix with values from Node v26.3.0 that fails without either cast. No security-sensitive surface is touched (expired() is only reachable via process.binding("http_parser") from user code), and no CODEOWNERS entry covers the changed files. The one inline finding concerns the deadline-less busy-wait in the new test, which exits in about 2 ms today and is a quality nit rather than a correctness issue in the fix.

idle: list.idle(),
}).toEqual({ headersCompleted: [false, true], idle: [] });
// Both parsers are older than 1 ms.
while (incomplete.duration() < 2 || complete.duration() < 2) {}

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.

🟡 (optional) CI runners get a silent whole-file hang instead of a test failure if duration() ever stops advancing for one of the two parsers. The spin at node-http-parser.test.ts:480 has no deadline, and because it is synchronous the test runner's per-test timeout cannot fire. Any future regression in HTTPParser::duration() or in initialize() setting m_lastMessageStart (NodeHTTPParser.cpp:278) turns this test from red into a stuck job. Fix: bound the poll with a wall-clock deadline (for example a performance.now() limit of a few hundred ms) and fail with a message naming which parser's duration() stayed below 2, keeping the condition-polled shape.

Why this was flagged

The loop while (incomplete.duration() < 2 || complete.duration() < 2) {} at test/js/node/http/node-http-parser.test.ts:480 runs on every bun test invocation of this file, seven times per run via test.each. Today it exits in about 2 ms because NodeHTTPParser.cpp:278 and :358 set m_lastMessageStart and NodeHTTPParser.cpp:325 computes uv_hrtime() minus that value. The dismissal assumed the preceding idle: [] assertion at :475-:478 is a sufficient guard, but that assertion only proves lastMessageStart is non-zero at that instant; it does not bound the loop if duration() later returns a constant (for example a regression that returns 0 for parsers in a list, or reads a cached value). Being synchronous, no setTimeout-based test timeout can interrupt it, so the file hangs until the CI job limit. Root CLAUDE.md says to poll with a deadline. Remedy: add a deadline to the poll and throw with the observed durations when it is crossed.

Verification: nit — triggers only if a future regression makes HTTPParser::duration() stop advancing. test/js/node/http/node-http-parser.test.ts:480 is a synchronous spin with no wall-clock bound. Per-test timeout cannot fire: bun test's timeout is enforced by the event loop (src/runtime/test_runner/Execution.rs:298-335). So a sync spin turns a would-be assertion failure into a hung bun test process.

This branch has not been deployed

No deployments
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.

1 participant