Skip to content

fix(ext/node): address node:http rewrite review comments#33299

Merged
bartlomieju merged 10 commits into
mainfrom
fix/node-http-review-comments
Apr 17, 2026
Merged

fix(ext/node): address node:http rewrite review comments#33299
bartlomieju merged 10 commits into
mainfrom
fix/node-http-review-comments

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Apr 16, 2026

Copy link
Copy Markdown
Member

Summary

  • Review comment fixes (feat(ext/node): rewrite node:http with llhttp and native TCPWrap #33208): restore emitBindingWarning() in internal/test/binding.ts, add comment explaining root cert store fallback in tls_wrap.rs, remove setUnrefTimeout from node:timers public exports, add comment explaining sync pipe writes
  • writeHead statusMessage validation: validate statusMessage for invalid characters using checkInvalidHeaderChar, throwing ERR_INVALID_CHAR to prevent CRLF injection
  • rejectNonStandardBodyWrites: add ERR_HTTP_BODY_NOT_ALLOWED error and throw in OutgoingMessage write path when _hasBody is false and rejectNonStandardBodyWrites is enabled
  • AsyncLocalStorage through HTTP parser: store the async resource passed to parser.initialize() and wrap execute() with AsyncResource.runInAsyncScope(), emulating Node's MakeCallback behavior to preserve AsyncLocalStorage context through native HTTP parser callbacks
  • TLS shutdown during handshake: defer send_close_notify() and underlying stream shutdown when TLS handshake is still in progress, executing them after handshake completes. Fixes rustls DecodeError when conn.end('') is called before the connect callback
  • Fix null pointer crash in llhttp finish(): llhttp_finish() can trigger callbacks (e.g. on_message_complete) but finish() was called without setting up the ExecuteContext, causing a null pointer dereference when the client-side HTTP parser processed a rejected-upgrade response. Now passes the callbacks object and scope to finish(), matching execute().

Closes #28654

Test plan

  • test-http-status-reason-invalid-chars — now passing
  • test-http-head-throw-on-response-body-write — now passing
  • test-async-local-storage-http-multiclients — now passing
  • test-tls-zero-clear-in — now passing
  • [node/http] upgrade rejection via socket.write + socket.end does not crash — new test

🤖 Generated with Claude Code

bartlomieju and others added 6 commits April 16, 2026 21:17
- Restore emitBindingWarning() in internal/test/binding.ts
- Add comment explaining root cert store fallback in tls_wrap.rs
- Validate statusMessage for invalid chars in writeHead() (CRLF injection)
- Remove setUnrefTimeout from node:timers public exports
- Add comment explaining sync pipe writes
- Enable test-http-status-reason-invalid-chars compat test

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add ERR_HTTP_BODY_NOT_ALLOWED error and throw it in OutgoingMessage
write path when _hasBody is false and rejectNonStandardBodyWrites is
enabled. Enables test-http-head-throw-on-response-body-write compat test.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Store the async resource passed to parser.initialize() and wrap
execute() with AsyncResource.runInAsyncScope(), emulating Node's
MakeCallback behavior. This ensures AsyncLocalStorage context is
preserved through native HTTP parser callbacks.

Enables test-async-local-storage-http-multiclients compat test.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When shutdown() is called before the TLS handshake completes (e.g.
conn.end('') before the connect callback), defer send_close_notify()
and underlying stream shutdown until after the handshake finishes.
Previously rustls would send malformed data mid-handshake, causing
the peer to emit a fatal DecodeError alert.

Enables test-tls-zero-clear-in compat test.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The restored emitBindingWarning() emits a process warning when
internal test bindings are used. Update .out files to expect this
warning line. Also fix clippy lint (map_or -> is_some_and).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
bartlomieju and others added 4 commits April 17, 2026 07:09
The internalBinding warning fires during the call, before the pipe
close callback, so it appears before the PASS line.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
…comments

# Conflicts:
#	ext/node/polyfills/internal_binding/pipe_wrap.ts
llhttp_finish() can trigger callbacks (e.g. on_message_complete) but
finish() was called without setting up the ExecuteContext on parser.data.
This caused a null pointer dereference when the HTTP parser received
EOF on an upgrade-rejected connection.

Pass the callbacks object and scope to finish(), matching execute().

Fixes #28654

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Regression test for #28654: socket.write() + socket.end() in an
upgrade handler must not crash the HTTP parser.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@miracatbot

Copy link
Copy Markdown

Most of this looks like good review-followup work, but I think the TLS shutdown fix changes behavior in a risky way.

Node’s TLSWrap::DoShutdown() always marks shutdown, sends close_notify, flushes encrypted output, and then immediately forwards DoShutdown() to the underlying stream. This patch now defers the underlying shutdown entirely while is_handshaking() is true, and only performs it later from the handshake-done callback.

That avoids the rustls decode error for conn.end('') before 'connect', but it also means a socket that is closed during a handshake no longer starts the transport shutdown until the handshake completes. If the peer stalls or never finishes the handshake, we’ve now converted a local shutdown request into “wait indefinitely for the peer to finish handshaking first”.

I’d want either a stronger proof that rustls will always drive handshake_done/error after this path, or a regression test that destroys/ends during handshake when the peer never completes, and verifies the transport still closes promptly instead of hanging.

@bartlomieju

Copy link
Copy Markdown
Member Author

Good catch on the stalled-handshake concern. I tested this:

socket.end() during stalled handshake — hangs in both Deno and Node.js:

const server = net.createServer((socket) => { /* never send TLS data */ });
const socket = tls.connect({ port, rejectUnauthorized: false });
setTimeout(() => socket.end(), 100);
// Both Deno and Node: socket never closes

socket.destroy() during stalled handshake — closes promptly in both:

setTimeout(() => socket.destroy(), 100);
// Both Deno and Node: socket closes immediately

So the behavior matches Node.js. end() is a graceful close that waits for the writable side to finish — if the handshake never completes, there's no way to send close_notify, so it waits (same as Node). destroy() bypasses this and closes immediately. Applications that need to handle stalled peers use socket.setTimeout() + socket.destroy(), which works correctly.

@miracatbot miracatbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

@bartlomieju
bartlomieju merged commit 7eda90e into main Apr 17, 2026
113 checks passed
@bartlomieju
bartlomieju deleted the fix/node-http-review-comments branch April 17, 2026 09:00
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.

Socket hangs when using socket.write + socket.end in an upgrade request

2 participants