Skip to content

fix(http): client write/end callbacks + backpressure, real client timeouts, dynamic listener registration (#4909)#4964

Merged
proggeramlug merged 2 commits into
mainfrom
fix/4909-client-write-end-timeouts
Jun 11, 2026
Merged

fix(http): client write/end callbacks + backpressure, real client timeouts, dynamic listener registration (#4909)#4964
proggeramlug merged 2 commits into
mainfrom
fix/4909-client-write-end-timeouts

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Sub-tickets (1) client-side OutgoingMessage write/end callbacks + backpressure and (2) client HTTP timeouts from the #4909 triage, mirroring the #4954 server-side fix onto the perry-ext-http client path — plus the cross-cutting dispatch bugs that kept the corpus shapes hanging.

Root causes fixed

Client req.write / req.end (perry-ext-http)

  • The static dispatch table routed req.write/req.end to single-arg entry points: the (encoding?, callback?) tail was dropped and write() returned the always-truthy handle, so while (req.write(buf, cb)) producer loops never terminated (the dominant test-http-outgoing-* hang). New js_http_client_request_write_full / _end_full queue the callbacks, return a real NaN-boxed backpressure boolean (16 KiB HWM), and accept Buffer chunks (previously misread as StringHeaders — same layout confusion as node:http res.write(Buffer)/res.end(Buffer): binary contents zeroed on wire (length preserved) #1124).
  • Flush ordering matches Node via a new PendingHttpEvent::Flushed: write callbacks → 'finish' → end callback.

Client timeouts

  • req.setTimeout(ms, cb) only stored the delay; the deadline existed only once a request was dispatched. The canonical req.setTimeout(n); req.on('timeout', …) against a never-responding server (or before req.end()) hung forever. A real timer is now armed at setTimeout() / at request creation for options.timeout; 'timeout' fires at most once and never after completion; setTimeout(0) clears, per Node.
  • req.destroy() on an in-flight request emits the coded ECONNRESET "socket hang up" then 'close' (once-only); response/error drains are suppressed for completed/destroyed requests; .on('response') listeners now fire alongside the factory callback.

Dynamic dispatch (untyped out receivers — function write(out) { … } corpus shape)

  • out.on('finish', cb) silently dropped the listener: the stdlib dispatch_http.rs name-gate had no "on" in the method allowlist. Added on/once/addListener/prependListener/removeListener/off/removeAllListeners to the gate and the ext dispatcher.
  • out.constructor.name now reads ClientRequest/ServerResponse/IncomingMessage (plain { name } stand-in object).

Server side (perry-ext-http-server)

  • req.resume() consumed the one-shot 'end'/'data' emit flags even with no listener registered yet, so the canonical drain pattern req.resume(); req.on('end', cb) never fired 'end' and the server never responded — a second, independent cause of the silent hangs.
  • Dynamic res.write(chunk, cb) (js_node_http_res_write_with_cb) always returned 1 — no backpressure (the fix(http): wire res.write/res.end callbacks + backpressure boolean into static dispatch (#4909) #4954 fix only covered the static path); now returns the same HWM boolean.
  • Dynamic res.end(buf, cb) crashed with TypeError: value is not a function: closure_arg accepted any POINTER_TAG value, so the Buffer chunk was taken as the end(cb) callback form and invoked. Now validated via js_value_is_closure.
  • Dynamic end_with_cb fired 'finish'/'close' before the callbacks; reordered to Node's write-cbs → 'finish' → end-cb → 'close' (matching js_node_http_res_end_full).

Validation

  • node:http/https: 89 tests exit non-zero with no output (triage; likely server-hang/framing, partly cascades from #4903) #4909 bucket re-measured (108 tests, baseline origin/main build vs this branch, same raw-byte comparator, 8 s cutoff): +8 fail→pass, 0 regressionstest-http-outgoing-finish (byte-for-byte), test-http-client-timeout-event, test-http-agent-uninitialized(-with-handle), test-http-early-hints, plus test-http-client-race(-2) and test-http-timeout-client-warning which now differ only by Node's DEP0169-stderr/pid noise that node_core_subset.py's normalizer strips.
  • Most remaining bucket entries now exit with a real assertion message instead of the silent (no output) hang this issue tracks — i.e. the triage acceptance ("capture the actual non-zero exit cause") holds for the residue.
  • New e2e regression test crates/perry/tests/issue_4909_client_write_end_timeouts.rs (both green): flush-order + backpressure round-trip, and setTimeout→'timeout'→destroy→ECONNRESET→'close'.
  • cargo test -p perry-ext-http -p perry-ext-http-server green; curated node-suite http/https and the in-repo test_parity_http(s)/gap http tests match baseline (no flips).
  • test-http-outgoing-message-write-callback (fix(http): wire res.write/res.end callbacks + backpressure boolean into static dispatch (#4909) #4954's representative) still passes after the end_with_cb reorder.

Out of scope / follow-ups for #4909

  • 100-continue (checkContinue/writeContinue), agent keep-alive socket reuse, streamed (multi-chunk) responses (res.flushHeaders + late chunks), server-side req/res/server.setTimeout timers (need a socket surface), raw-framing/smuggling cases.
  • Discovered (pre-existing, unchanged): test-http-response-setheaders.js SIGSEGVs nondeterministically (~6/8 runs) on both baseline and this branch — worth its own ticket.

Code-only PR per convention — no version bump / changelog (maintainer folds at merge).

Part of #4909 / #2132.

…eouts, dynamic listener registration (#4909)

Sub-tickets (1) and (2) of the #4909 silent-hang triage, mirroring the
#4954 server-side fix onto the perry-ext-http client path:

- req.write(chunk[, enc][, cb]) queues its callback and returns a real
  NaN-boxed backpressure boolean (false past the 16 KiB high-water
  mark), so `while (req.write(buf, cb))` producer loops terminate.
  Buffer chunks are read through a buffer-aware reader instead of being
  misparsed as StringHeaders (same layout confusion as #1124).
- req.end([chunk][, enc][, cb]) handles the end(cb) form and fires the
  queued write callbacks -> 'finish' -> end callback in Node's flush
  order via a new PendingHttpEvent::Flushed drained by the client pump.
- req.setTimeout(ms[, cb]) arms a real timer (tokio sleep -> Timeout
  event) instead of only storing the delay, so 'timeout' fires even for
  a request that was never dispatched or whose server never responds;
  options.timeout arms the same timer at request creation. 'timeout' is
  emitted at most once and never after completion; setTimeout(0)
  clears the timer per Node.
- req.destroy() on an in-flight request emits the coded ECONNRESET
  "socket hang up" then 'close' (once-only via close_emitted); the
  response/error drains suppress events for completed/destroyed
  requests; '.on("response")' listeners now fire alongside the factory
  callback.
- Dynamic dispatch (untyped receivers): on/once/addListener/
  removeListener/removeAllListeners now register/remove client-request
  listeners (the stdlib dispatch_http name-gate hid "on" so listeners
  were silently dropped), and `constructor` returns a `{ name }` object
  so out.constructor.name discriminates ClientRequest/ServerResponse/
  IncomingMessage.
- Server side: js_node_http_im_resume no longer consumes the one-shot
  'end'/'data' emit flags when no listener is registered yet, fixing
  the canonical `req.resume(); req.on('end', cb)` drain pattern that
  hung every response; js_node_http_res_write_with_cb returns the same
  backpressure boolean as the static _full path; js_node_http_res_end_with_cb
  fires write cbs -> 'finish' -> end cb -> 'close' in Node order; and
  closure_arg validates with js_value_is_closure so a Buffer chunk in
  `res.end(buf, cb)` is no longer invoked as the callback (TypeError).

Corpus bucket (#4909, 108 tests, baseline vs fixed on the same
comparator): +8 fail->pass (outgoing-finish, client-timeout-event,
agent-uninitialized x2, early-hints, client-race x2 and
timeout-client-warning — the latter three byte-differ only by Node's
DEP0169/pid noise the radar normalizes), 0 regressions; most remaining
runtime-fails now exit with a real assertion message instead of the
silent hang this issue tracks.

Found while triaging (pre-existing, unchanged): test-http-response-setheaders
SIGSEGVs nondeterministically on baseline and fixed builds alike.
@proggeramlug proggeramlug merged commit a34d062 into main Jun 11, 2026
12 of 13 checks passed
@proggeramlug proggeramlug deleted the fix/4909-client-write-end-timeouts branch June 11, 2026 05:04
Sign up for free to 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.

1 participant