Skip to content

fix(ext/node): fix TLS socket lifecycle and readStop backpressure#33524

Merged
bartlomieju merged 3 commits into
mainfrom
fix/tls-readstop-backpressure
Apr 26, 2026
Merged

fix(ext/node): fix TLS socket lifecycle and readStop backpressure#33524
bartlomieju merged 3 commits into
mainfrom
fix/tls-readstop-backpressure

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Summary

  • Fix readStop bypassing backpressure in TLSWrap -- do_emit_read had a JS property fallback that delivered data even after readStop, breaking flow control
  • Add pending_clear_out / pending_eof buffering so data received during readStop is not lost
  • Flush buffered data/EOF in readStart to handle close_notify received during handshake before onread is installed
  • Add this._handle.readStart() in _start() to kick-start the TLS readable side after handshake
  • Fix net.Socket._onReadableStreamEnd to call this.end() when allowHalfOpen is false, matching Node.js -- without this, sockets never sent close_notify back to the peer
  • Remove unused OpState param from TLSWrap::read_start to prevent reentrant RefCell panics

Fixes 7+ previously-hanging node compat TLS tests:
test-tls-connect-simple, test-tls-close-event-after-write, test-tls-close-notify, test-tls-econnreset, test-tls-ca-concat, test-tls-connect-hwm-option, test-tls-destroy-whilst-write, test-tls-client-abort2

Likely related to #33266 (Wrangler asset upload regression).

Test plan

  • New spec test tls_readstop_backpressure -- sends 64KB through TLS with 1KB highWaterMark, verifies all data arrives and end fires
  • Existing spec tests pass (tls_keepalive_large_body, tls_jsstreamsocket_close)
  • 7 previously-T/O node compat TLS tests now pass

readStop() clears inner.onread to stop delivering decrypted data, but
do_emit_read() had a fallback that looked up "onread" on the JS handle
object. This defeated backpressure entirely -- data and EOF kept flowing
to the stream even after readStop was called.

Fix:
- Remove the JS property fallback in do_emit_read. When onread is None,
  return immediately instead of bypassing readStop.
- Buffer decrypted cleartext in pending_clear_out when readStop is
  active, so data is not lost.
- Defer EOF delivery via pending_eof flag when readStop is active.
- Flush buffered data and deferred EOF in read_start before the normal
  cycle, preserving data ordering.
- Update emit_eof to also respect readStop state.
@bartlomieju
bartlomieju force-pushed the fix/tls-readstop-backpressure branch from 09ca30f to 946b324 Compare April 26, 2026 11:00
The _onReadableStreamEnd change to call socket.end() via nextTick
broke the child-process-stdio-merge-stdouts-into-cat test on Windows.
When child processes share a pipe handle (p3.stdin) as stdout, the
auto-end closes the pipe prematurely when one child exits, preventing
subsequent children from writing through it. The TLS backpressure fix
does not depend on this change.

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

LGTM. Walked through the lifecycle change with the assumption something was wrong; the design holds up:

  • Removing the recv.get(scope, key.into()) fallback in do_emit_read is correct -- inner.onread is the single source of truth (set in read_start, cleared in read_stop), and the JS-side property lookup was racing the readStop bookkeeping.
  • The buffering invariants are preserved: pending_clear_out is only ever populated when onread.is_none() (matched in both cycle sites and the new dispatch_clear_out_callbacks site), and read_start drains it BEFORE cycle runs again, so consumers see decrypted bytes in arrival order. The pending_eof-after-pending_data ordering in the flush block matches that.
  • _start() calling this._handle.readStart() is the right way to flush a close_notify that landed during handshake (before JS installed onread); the call is idempotent because read_start re-grabs onread from the same JS handle each time and pending_clear_out is only non-empty on the first call after a stop.
  • _onReadableStreamEnd's nextTick-deferred socket.end() (gated on writable && !destroyed) safely handles the case where user code already called end(). Matches Node's behavior of letting the user's end listener observe socket.writable === true.
  • The #[fast] -> #[nofast] flip on read_start is forced by the new flush block triggering v8 callbacks (do_emit_read) which can't run inside a fast-call frame; consistent with the dropped OpState arg.

New spec test exercises HWM=1024 over 64KB to force backpressure, asserts both byte count and end -- without the fix the safety timer fires and the test exits 1 loud, which is the right shape for a hang regression.

Comment thread ext/node/ops/tls_wrap.rs
// This must happen before the cycle below so the consumer sees the
// data in the order it was decrypted.
let inner_ptr = inner as *mut TLSWrapInner;
let pending_data = std::mem::take(&mut inner.pending_clear_out);

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.

Minor: this mem::take clears pending_clear_out before the extract_emit_ctx(...) check. If extract_emit_ctx returns None (isolate unset / js_handle dropped), the buffered bytes are silently dropped on the floor.

In practice this can't happen here -- read_start is reachable only from a v8 callback, so isolate and js_handle are both alive. But defensively you could either restore inner.pending_clear_out = pending_data; inner.pending_eof = pending_eof; in the None branch, or debug_assert!(extract_emit_ctx is Some). Not a blocker.

@bartlomieju
bartlomieju merged commit 9e9a919 into main Apr 26, 2026
112 checks passed
@bartlomieju
bartlomieju deleted the fix/tls-readstop-backpressure branch April 26, 2026 17:15

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

LGTM. Walked all five fixes:

  1. do_emit_read JS-property fallback removal — the recv.get(scope, "onread") lookup as a fallback for missing inner.onread is the actual bug: it bypasses readStop entirely (since readStop sets inner.onread = None, but the JS handle's onread property is still there from before). With it removed, missing onread correctly returns and lets the new buffering paths kick in.

  2. pending_clear_out / pending_eof buffering at the two cycle() callsites and at do_shutdown_complete — when (*ptr).onread.is_none(), decrypted data and EOF accumulate instead of being dropped. Order is preserved (the post-readStart flush replays data first, then EOF).

  3. read_start flush block — does the right thing in the right order: mem::take(&mut inner.pending_clear_out) before the cycle, dispatch buffered data via do_emit_read, then EOF. The let inner_ptr = inner as *mut TLSWrapInner is shared with the cycle below, which is fine since the buffered-flush path completes before the cycle runs.

  4. _start() calling this._handle.readStart() — close_notify can be processed during the handshake before inner.onread is set, so the EOF lands in pending_eof. Without the kick-start readStart() here, the 'end' event would never fire (test-tls-econnreset / test-tls-close-notify hang). With it, readStart installs onread and flushes.

  5. net.Socket._onReadableStreamEnd calling this.end() — pure Node-compat fix; allowHalfOpen=false is the spec-default and Node sends close_notify via this.end() in that case. The nextTick wrapper correctly guards the late-registered-handler-sees-writable=true case.

  6. OpState param removal from read_start — was unused, and the &mut OpState borrow could conflict with the JS-callback re-entry via do_emit_read if any callee touched OpState (correct preventive cleanup, though I didn't find a concrete reentrant panic on the current shape).

The new tls_readstop_backpressure spec test exercises the path explicitly (64KB through a 1KB-HWM stream → buffering must be involved) and asserts both byte count and 'end' firing.

One non-blocking concern inline.

Comment thread ext/node/ops/tls_wrap.rs
if let Some(ctx) = extract_emit_ctx(ptr) {
if (*ptr).onread.is_none() {
// readStop is active -- buffer the data for later delivery
(*ptr).pending_clear_out.extend_from_slice(&result.data);

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.

Non-blocking: pending_clear_out is unbounded — if a consumer holds readStop indefinitely while the peer keeps sending (and the underlying TCP receive buffer keeps draining into rustls), this extend_from_slice will keep growing. In practice the TCP-level backpressure should kick in once rustls stops draining, but worth a thought: a malicious peer + a forgotten readStop = unbounded memory. Node's node_tls_wrap.cc has a similar buffer, so this is more about awareness than a blocker.

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.

3 participants