Skip to content

node:quic: detach lsquic ctx pointers before the finalize-order sweep at worker VM teardown - #35761

Open
robobun wants to merge 10 commits into
mainfrom
farm/8481668d/quic-worker-finalizer-uaf
Open

robobun wants to merge 10 commits into
mainfrom
farm/8481668d/quic-worker-finalizer-uaf

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A Worker that used node:quic crashes when it ends or is terminated. On a release build of main (fd8422c) the repro segfaults 3 of 3 runs: panic: Segmentation fault at address 0x1, exit 139. Under ASAN it is heap-use-after-free READ 8 on the Worker thread, top frame nq_on_stream_close (packages/bun-usockets/src/node_quic_shim.c:127), reached through lsquic_stream_destroy <- ietf_full_conn_ci_destroy <- force_close_conn <- lsquic_engine_destroy <- QuicEndpoint::release_native <- QuicEndpoint::finalize.
  • WebWorker::shutdown -> VM::~VM -> Heap::lastChanceToFinalize sweeps the QUIC wrappers in unspecified order. QuicStream and QuicSession dropped their boxes without clearing the ctx pointer lsquic still held, so the endpoint finalizer's lsquic_engine_destroy called back into freed memory. The same root also shows as nq_on_conn_closed (shim.c:57) and as QuicSession::push_event via on_conn_closed or on_mini_conn_failed.

Fix

  • QuicStream::finalize and QuicSession::finalize clear lsquic's stream/conn ctx before the box drops. A non-null raw/conn always means the lsquic object is still live: on_close/on_conn_closed null it before lsquic frees the struct, and teardown() clears it on the orderly path. QuicStream::create now leaves raw null until bind_raw, after the fallible ArrayBuffer allocations, so a wrapper from a failed create never points at a stream lsquic still owns.
  • QuicEndpoint::detach_for_finalize runs before release_native. It clears the raw *mut QuicSession registries and nulls vtable.owner, so owner-keyed callbacks (on_mini_conn_failed, packets_out, on_new_conn, the SSL_CTX lookups) short-circuit in ctx_ref. on_new_stream resolves its session through the conn ctx instead, which QuicSession::finalize nulls.
  • It also swaps on_stream_close and on_conn_closed for variants that only clear the wrapper's own lsquic handle. A callback that reaches a wrapper the sweep has not freed yet then cannot follow that wrapper's session/endpoint back-pointer into a box that is already gone.
  • Verified: test/js/node/quic/quic-endpoint.test.ts (worker.terminate). Debug+ASAN fails 3 of 3 with src/ at main and passes 3 of 3 with the fix. USE_SYSTEM_BUN=1 (release 1.4.3) fails with the segfault. All of test/js/node/quic/ is 21/21.

Background

  • lastChanceToFinalize is the sweep VM::~VM runs so that every remaining cell's destructor runs before the heap goes away. It gives no ordering between cells, which is why an endpoint can outlive its own sessions here.
  • lsquic stores one opaque void* per connection and per stream. node_quic_shim.c reads the vtable pointer out of the front of that context on every callback, so a freed context is a wild read on the first instruction of the callback.
  • lsquic_engine_destroy is not passive. It force-closes every live connection, which drives on_close for each stream and on_conn_closed for each connection.
  • ctx_ref (src/runtime/node/quic/ffi.rs) is the one audited deref for these callbacks. A null context is its whole liveness test, so nulling a context is what makes a late callback a no-op.
Notes

First opened 2026-07-25 and closed unmerged on 2026-09-13 by a stale-PR cleanup ("This is not a judgment on the fix itself"). This push merges current main and resolves two conflicts: #40516 deleted the empty QuicStream::finalize and QuicSession::finalize bodies in favour of the JsFinalize default, and this change fills those bodies in again. The raw/conn invariants the fix relies on are unchanged on main, as is the shim's null check.

What changed about the surface since the original post: node:quic ships in releases now, so this is no longer ASAN-lane only. 1.4.3 segfaults. 1.3.14 has no node:quic.

The fixture is deterministic rather than timing-based. JSC's IsoSubspace lower tier puts the first 8 cells of each wrapper type in PreciseAllocations and the rest in a MarkedBlock, and lastChanceToFinalize sweeps MarkedBlocks first. Holding twelve live sessions plus their streams pushes those wrappers past the lower tier while the two endpoints stay in it, so the endpoint finalizer always runs after the session and stream boxes are freed. That is 8/8 fail-before where the earlier sleep-based version was about 4/8, and one run takes roughly 10s instead of 80s.

Callbacks reachable from lsquic_engine_destroy, from the vendored source: destroy_conn (lsquic_engine.c:994-1014) calls on_mini_conn_failed for a mini conn, then ci_destroy. ietf_full_conn_ci_destroy (lsquic_full_conn_ietf.c:3202) calls lsquic_stream_destroy for each stream, which calls on_close (lsquic_stream.c:663), and then on_conn_closed (line 3251). on_hsk_done and on_hsk_confirmed are reached only from the crypto stream's handshake progress (lsquic_enc_sess_ietf.c:2044-2050 via ci_hsk_done), never from the destroy path. So the three callbacks the detach covers are the full set.

Four faces of the one root were observed while reducing it: nq_on_stream_close (stream ctx), nq_on_conn_closed (conn ctx), QuicSession::push_event from on_conn_closed, and QuicSession::push_event from on_mini_conn_failed reaching a freed session through the endpoint's provisional list.

Open #40220 (node:quic: remove unsafe from the endpoint, session, stream and TLS code) makes these contexts refcounted and lists a terminated-Worker hand test. It is a 26-file refactor, last pushed 2026-08-29, and currently conflicts with main, so it is not a near-term fix for this crash.


[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/quic/quic-endpoint.test.ts
bun test v1.4.3 (b52d51348)

test/js/node/quic/quic-endpoint.test.ts:
(node:12196) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [298.54ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [377.27ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [74.40ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [4175.44ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [221.28ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [92.23ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [116.51ms]
332 |     });
333 |     const [s
... (truncated)

release without fix: 1 FAILED
bun test v1.4.3-canary.1 (b52d51348)

test/js/node/quic/quic-endpoint.test.ts:
(node:12256) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [12.49ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [9.54ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [3.76ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [73.51ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [8.10ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [10.95ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [10.42ms]
332 |     });
333 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
334 |     // stderr carries the per-worker ExperimentalWarning (random blob U
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/quic/quic-endpoint.test.ts
bun test v1.4.3 (b52d51348)

test/js/node/quic/quic-endpoint.test.ts:
(node:12846) ExperimentalWarning: quic is an experimental feature and might change at any time
(Use `bun-debug --trace-warnings ...` to show where the warning was created)
(pass) QuicEndpoint client-engine mode > an explicit endpoint rejects a connect() in the other mode [389.01ms]
(pass) endpoint blockList > applies to packets forwarded from a sibling endpoint on the same loop [286.97ms]
(pass) server ALPN list > rejects a list mixing HTTP/3 and non-HTTP/3 protocols [53.70ms]
(pass) node:quic under --isolate > a second test file in the same process gets its own callbacks [3865.60ms]
(pass) dual-mode endpoint > does not stateless-reset its own client connection [182.42ms]
(pass) transportParams.maxIdleTimeout > 0 disables the idle timeout instead of falling back to the default [83.56ms]
(pass) endpoint.close() while a session is live > stops accepting new sessions so closed can resolve [85.40ms]
(pass) node:quic in a Worker > wo
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     971e764ec6
  features     lto, baseline

23 deps, 131 codegen, 1176 objects in 767ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1250] install /workspace/bun
bun install v1.4.3-canary.1 (b52d51348)

Checked 22 installs across 61 packages (no changes) [9.00ms]
[2/1250] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b52d51348)

Checked 1 install across 2 packages (no changes) [5.00ms]
[3/1250] gen ErrorCode+*.h
[4/1250] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b52d51348)

Checked 111 installs across 104 packages (no changes) [10.00ms]
[5/1250] gen node-fallbacks/react-refresh.js
Bundled 1 module in 8ms

  react-refresh.js  4.81 KB  (entry point)

[6/1250] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1223] fetch tinycc
[tinycc] up to date
[8/1222] fetch zlib
[zlib] up to date
[9/1222] gen bindgenv2
[10/1222] gen .bind.ts → GeneratedBindings.cpp
[11/1222] gen bake.{client,server,error}.js
-> bake.client.js, bake.s
... (truncated)
diff hotspot
src/runtime/node/quic/endpoint.rs                  | 24 ++++++++
 src/runtime/node/quic/session.rs                   | 23 ++++++++
 src/runtime/node/quic/stream.rs                    | 24 ++++++++
 test/js/node/quic/quic-endpoint.test.ts            | 27 +++++++++
 test/js/node/quic/quic-worker-terminate-fixture.ts | 68 ++++++++++++++++++++++
 5 files changed, 166 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                reads  edits  tests
src/runtime/node/quic/endpoint.rs                       0      0      0
src/runtime/node/quic/session.rs                        0      0      0
src/runtime/node/quic/stream.rs                         0      0      0
test/js/node/quic/quic-endpoint.test.ts                 0      0      0
test/js/node/quic/quic-worker-terminate-fixture.ts      0      0      0

root cause · written by the author bot

When a Worker's VM is torn down, the finalize-order sweep can run QUIC endpoint, session, and stream finalizers while lsquic still holds raw context pointers to those objects, so native callbacks fired during native release could dereference freed memory. The fix detaches those pointers before any native resource is released: the endpoint clears its callback-visible registries and vtable callbacks first, detached callbacks null out the native pointer slots, and the session and stream finalizers clear the lsquic connection and stream contexts. A regression test terminates a worker with activ…

Worker VM teardown runs Heap::lastChanceToFinalize, which sweeps the
QuicEndpoint / QuicSession / QuicStream wrappers in unspecified order.
QuicEndpoint::finalize -> lsquic_engine_destroy drives on_close /
on_conn_closed / on_mini_conn_failed into per-stream and per-session
context pointers that lsquic still holds; when those wrappers were
swept first the ctx is a freed box and the shim's vtable read is a
heap-use-after-free (nq_on_stream_close, nq_on_conn_closed, or
QuicSession::push_event via on_mini_conn_failed).

QuicStream::finalize and QuicSession::finalize now clear lsquic's
stream/conn ctx before their box drops (the lsquic object is still
live whenever that field is non-null, since on_close/teardown null it
first). QuicEndpoint::finalize clears its raw session-pointer
registries and nulls the vtable owner so owner-keyed callbacks no-op,
and swaps on_stream_close / on_conn_closed for variants that only
clear the lsquic back-reference, so a callback that does reach a live
box cannot follow its session/endpoint back-pointer into a box the
sweep already freed.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 833fbeb9-7851-40a2-b1a2-22adb34e830e

📥 Commits

Reviewing files that changed from the base of the PR and between 9442f4f and c14090c.

📒 Files selected for processing (5)
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/node/quic/session.rs
  • src/runtime/node/quic/stream.rs
  • test/js/node/quic/quic-endpoint.test.ts
  • test/js/node/quic/quic-worker-terminate-fixture.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Changes

QUIC finalization safety

Layer / File(s) Summary
Endpoint detachment before native release
src/runtime/node/quic/endpoint.rs
QuicEndpoint clears callback-visible registries, detaches vtable ownership and callbacks, and performs this before releasing native resources.
Session and stream context cleanup
src/runtime/node/quic/session.rs, src/runtime/node/quic/stream.rs
Detached callbacks clear native pointer slots. Stream creation binds the native stream after setup succeeds. Finalizers clear lsquic connection and stream contexts.
Worker termination regression coverage
test/js/node/quic/quic-worker-terminate-fixture.ts, test/js/node/quic/quic-endpoint.test.ts
A worker creates active QUIC resources, is terminated twice, and the test verifies successful completion.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to c1409

The Worker teardown path now safely detaches QUIC callback state before native release; no actionable merge risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the primary change: detaching lsquic context pointers before the finalization sweep during Worker VM teardown. It is specific and related to the complete fix, although som…
Description check ✅ Passed The description explains the problem, root cause, fix, regression test, verification results, and relevant background. It does not use the template headings exactly, but it provides the required imple…

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reopened and brought up to date with main. The current head is c14090c. Buildkite #117396 passed and every GitHub check is green. The branch merges into main with no conflict, and no commit on main since the merge touches src/runtime/node/quic/, the shim, or lsquic.

Still reproduces on current main. Release build of fd8422c: panic: Segmentation fault at address 0x1, exit 139, 3 of 3 runs. Debug+ASAN: heap-use-after-free at nq_on_stream_close, 3 of 3.

Fail-before/pass-after on the merged tree, both directions with the gate's own procedure (git checkout --no-overlay origin/main -- src/ packages/, rebuild, run):

  • debug+ASAN, src/ at main: fails 3 of 3 (exit 1)
  • debug+ASAN, with the fix: passes 3 of 3
  • release 1.4.3 via USE_SYSTEM_BUN=1: fails with the segfault
  • all of test/js/node/quic/: 21/21

The merge resolved two conflicts. #40516 removed the empty QuicStream::finalize and QuicSession::finalize bodies in favour of the JsFinalize default. This change fills them in again. The raw/conn "non-null means live" invariants and the shim's null check are unchanged on main.

Two review findings landed after the merge. QuicStream::create leaves raw null until bind_raw, so a wrapper from a failed create never points at a stream that lsquic still owns (1bc6f1e). The fixture fails fast if the worker exits before it is up (c14090c). All review threads are resolved.

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:13 PM PT - Sep 17th, 2026

@robobun, your commit 1bc6f1e is building: #117377

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

The two inline findings are nits (test diagnostics/comment). I'm deferring on the native change itself: this is finalize-order UAF plumbing across the Rust↔lsquic FFI boundary with vtable hot-swapping and raw-pointer registry clearing — REVIEW.md's most-blocked category — and the load-bearing invariant ("non-null raw/conn ⇒ the lsquic struct is still live, so set_ctx(NULL) is safe from finalize") deserves a maintainer's eyes.

Beyond the inline findings, I checked that the swapped vtable is per-endpoint (self.vtable is owned by the endpoint box, still live inside its own finalize), and that the shim's *(us_nq_vtable**)ctx read is covered because already-swept stream/session boxes null their lsquic ctx before dropping so the shim's if (h) guard skips them.

Extended reasoning...

Overview

Fixes a heap-use-after-free at Worker VM teardown when a node:quic endpoint + session + stream are still live. Heap::lastChanceToFinalize sweeps the three wrapper types in unspecified order; QuicEndpoint::finalize runs lsquic_engine_destroy, which fires on_close/on_conn_closed/on_mini_conn_failed back into ctx pointers that may point at boxes the sweep already freed. The fix is three-pronged: (1) QuicStream::finalize / QuicSession::finalize null lsquic's ctx before their box drops; (2) QuicEndpoint::finalize clears its raw *mut QuicSession registries and nulls vtable.owner so owner-keyed callbacks short-circuit; (3) it swaps on_stream_close/on_conn_closed in the vtable for detached variants that only null the back-reference and never follow session/endpoint pointers into possibly-freed boxes. A subprocess fixture spins up six Worker rounds churning QUIC connections and terminate()s mid-flight; robobun confirmed it UAFs 3/3 before and passes 5/5 after under ASAN.

Security risks

None user-facing. This is a crash-safety fix in the teardown path; no input parsing, auth, or trust-boundary changes.

Level of scrutiny

High. This is native memory-safety code in the FFI layer — raw pointers, unsafe extern "C" callbacks, vtable mutation, and reasoning about JSC GC sweep order vs. lsquic's internal destroy sequence. The correctness of set_ctx(NULL) inside finalize rests on the documented invariant that a non-null raw/conn implies the lsquic object hasn't been freed yet (because the normal on_close/on_conn_closed and teardown() paths null it first). That invariant reads correctly to me against node_quic_shim.c and the existing teardown() bodies, but it's exactly the kind of cross-file lifetime contract a maintainer who owns this subsystem should confirm. I also verified the vtable being mutated is per-endpoint (owned by self, still live inside its own finalizer), so the swap doesn't affect other endpoints.

Other factors

The two findings from the bug-hunting pass are both nits on the test (a missing rationale comment on the intentional 200–500ms sleep, and stderr being drained-then-discarded so an ASAN backtrace wouldn't appear in the CI failure diff on regression). Neither affects the fix's correctness. The PR description and in-code comments are unusually thorough about the mechanism, and the ASAN repro is verified both ways — but per the approval guidelines, complex native lifetime changes are not auto-approvable regardless of how well they're written up.

Comment thread test/js/node/quic/quic-worker-terminate-fixture.ts Outdated
Comment thread test/js/node/quic/quic-endpoint.test.ts Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/endpoint.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/stream.rs Outdated
Comment thread src/runtime/node/quic/stream.rs Outdated
States the sweep-order invariant once on QuicEndpoint::detach_for_finalize
and has the stream/session sites reference it instead of restating.
Comment thread src/runtime/node/quic/endpoint.rs
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/session.rs Outdated
Comment thread src/runtime/node/quic/stream.rs Outdated
Comment thread src/runtime/node/quic/stream.rs Outdated

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

No bugs found, but this is a finalize-order UAF fix in native lsquic FFI — the memory-safety category REVIEW.md flags as most-blocked — so it warrants a human look.

What was reviewed:

  • Traced all six sweep orderings (endpoint/session/stream first) through the shim's ctx null-checks, the vtable-box lifetime during release_native, and the "non-null raw/conn ⇒ lsquic object live" invariant the new finalize bodies rely on.
  • Confirmed lsquic_engine_destroy's close path only drives on_close/on_conn_closed (ctx-keyed) plus owner-keyed callbacks — the two swapped stubs plus vt.owner = null cover them; on_reset/on_hsk_done/etc. don't fire during destroy.
  • The two earlier nits (surface stderr in the assertion; comment the fixture sleep) are addressed in ddb0441; the detach_for_finalize doc comment reads as an invariant contract, not a workaround justification.
Extended reasoning...

Overview

The PR fixes a heap-use-after-free at Worker VM teardown when lastChanceToFinalize sweeps live node:quic wrappers in unspecified order. Three Rust files gain finalize-time detach logic (QuicStream::finalize / QuicSession::finalize null their lsquic ctx; QuicEndpoint::finalize gains detach_for_finalize which clears raw-pointer registries, nulls vtable.owner, and swaps on_stream_close/on_conn_closed for back-pointer-free stubs before lsquic_engine_destroy runs). A new subprocess fixture spins Workers churning QUIC sessions and terminates them mid-flight; ASAN caught the UAF 3/3 before and passes 5/5 after.

Security risks

None introduced. This is a defensive detach on the teardown path; no new user-reachable surface, no input parsing. The failure mode being fixed is itself the security-relevant one (UAF).

Level of scrutiny

High. This is raw-pointer lifetime management across an FFI boundary during GC finalization — exactly the class REVIEW.md calls out as most-blocked. The correctness argument depends on three non-local invariants: (1) the shim null-checks ctx before dereferencing to read the vtable pointer (verified in node_quic_shim.c), (2) the vtable Box lives inside the endpoint and stays alive through release_native because Box<Self> drops only after finalize returns, and (3) a non-null raw/conn always denotes a live lsquic object because every path that frees it (on_close, on_close_detached, teardown) nulls the field first. I traced each and they hold, but a human should confirm — particularly that lsquic_engine_destroy cannot drive any ctx-keyed callback other than the two that were swapped (I checked ietf_full_conn_ci_destroy → lsquic_stream_destroy and believe it cannot, but this is vendored-C reasoning).

Other factors

The two nits from the earlier bug-hunt run (stderr in the assertion object, comment on the fixture sleep) are addressed in the current diff. The comment-cop bot flagged the detach_for_finalize doc comment; the author's response is reasonable — it's the single place stating a cross-file invariant that five SAFETY: sites reference, not a workaround justification. The fixture loops 10 rounds (vs. 6 in the description) with a 120s test timeout, which is on the slow side but justified for an ASAN UAF repro. robobun independently confirmed the test fails without the src/ change and passes with it.

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

Both earlier nits are addressed (stderr now surfaces in the failure diff; the fixture sleep is documented). No further issues found, but this is a finalize-order UAF fix across raw lsquic FFI back-pointers — worth a human pass on the invariants before merge.

Traced all six sweep orderings of endpoint/session/stream through detach_for_finalize → lsquic_engine_destroy: each either sees a nulled ctx (already-finalized wrapper cleared it) or reaches a still-live box via the swapped on_*_detached callback that only nulls its own raw/conn and never follows the possibly-freed back-pointer.
Confirmed ctx_ref returns None on null, so vtable.owner = null_mut() short-circuits the owner-keyed callbacks (on_mini_conn_failed, packets_out, on_new_conn/on_new_stream).
Checked that during ordinary (non-lastChanceToFinalize) GC the self-Strong means finalize only runs after teardown() has already nulled raw/conn, so the new set_ctx(null) is a no-op there and cannot touch a freed lsquic object.

Extended reasoning...

Overview

The PR fixes a heap-use-after-free at Worker VM teardown when node:quic wrappers are live. Heap::lastChanceToFinalize sweeps QuicEndpoint / QuicSession / QuicStream in unspecified order; the endpoint's finalizer runs lsquic_engine_destroy, which calls back through per-conn/per-stream ctx pointers that may point at already-freed session/stream boxes. The fix is three-pronged: (1) session/stream finalize null their lsquic ctx before dropping so the shim skips them; (2) endpoint finalize clears its raw *mut QuicSession registries and nulls vtable.owner so owner-keyed callbacks short-circuit in ctx_ref; (3) endpoint swaps on_stream_close/on_conn_closed for detached variants that only null the wrapper's own lsquic handle and never follow the session/endpoint back-pointer into a possibly-freed sibling. An ASAN subprocess test (10 worker rounds, 3 concurrent connect loops churning 1 MiB streams, then terminate()) reproduces 3/3 without the fix and passes 5/5 with it.

Security risks

None introduced. This closes a memory-safety hole (UAF reachable from JS via worker.terminate()); the change adds no new user-facing surface and only narrows what callbacks may touch during teardown.

Level of scrutiny

High. This is squarely in REVIEW.md's most-blocked category — native memory safety, raw pointers across an FFI boundary, GC finalize ordering, and cross-object back-pointers. The correctness argument depends on invariants stated in comments ("a non-null raw/conn is live because on_close/on_conn_closed/teardown null it first") that hold across three Rust files and a C shim. I traced the six sweep permutations and each reads sound, but a maintainer who owns the QUIC subsystem should confirm — in particular that no other lsquic callback beyond the ones handled (stream close, conn closed, mini-conn-failed, packets_out, new_conn/new_stream, SSL_CTX lookup) can fire during lsquic_engine_destroy and reach a wrapper box.

Other factors

My two earlier inline nits were addressed in ddb0441 (stderr in the asserted object via expect.any(String)) and the fixture now carries the required comment on the ≥50 ms sleep. The comment-cop flags were resolved by consolidating the rationale into a single doc comment on detach_for_finalize with one-line SAFETY references at each site, which robobun defended and I agree is the right shape — the doc comment states a non-local invariant, not a workaround. The test follows harness conventions (drains both pipes concurrently, bunEnv, subprocess fixture for the crash repro) and the PR evidence shows it fails on main under ASAN and passes with the fix.

…Subspace lower-tier

Hold twelve live sessions+streams so the session/stream wrappers spill
past numberOfLowerTierPreciseCells into a MarkedBlock while the two
endpoints stay in lower-tier PreciseAllocations; lastChanceToFinalize
sweeps MarkedBlocks first, so the endpoint finalizer's
lsquic_engine_destroy always sees already-freed ctx pointers without the
fix. 8/8 fail-before, 8/8 pass-after, ~10s per run.

@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/js/node/quic/quic-endpoint.test.ts`:
- Around line 277-294: Update the worker.terminate test declaration to use
test.concurrent because it runs an independent subprocess. Replace the fixed
30000ms timeout with the established ASAN/debug-scaled timeout, using isASAN and
isDebug from the test harness: 120000ms for either lane and 60000ms otherwise.
🪄 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: ee80c9fc-07bb-40ec-91f7-7d8c186e3116

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 9442f4f.

📒 Files selected for processing (5)
  • src/runtime/node/quic/endpoint.rs
  • src/runtime/node/quic/session.rs
  • src/runtime/node/quic/stream.rs
  • test/js/node/quic/quic-endpoint.test.ts
  • test/js/node/quic/quic-worker-terminate-fixture.ts

Comment thread test/js/node/quic/quic-endpoint.test.ts

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

Both earlier nits are addressed (stderr now surfaces in the assertion; the fixture was made deterministic via the IsoSubspace lower-tier trick, so the sleep concern is moot). I didn't find bugs, but this is a finalize-order UAF fix across three wrapper types, an FFI vtable swap, and lsquic's engine-destroy callback path — worth a human pass on the memory-safety reasoning.

What was reviewed:

  • finalize ctx-nulling: verified the "non-null ⇒ live" invariant holds — on_stream_close/on_conn_closed and teardown() each null raw/conn before lsquic frees the underlying object, so set_ctx(NULL) at finalize can't touch a freed lsquic struct.
  • Detached callbacks: on_close_detached/on_closed_detached only write the local raw/conn cell and never follow session/endpoint back-pointers; ctx_ref short-circuits on the null ctx that already-swept wrappers leave behind.
  • on_stream_reset/on_stream_read/on_stream_write are not swapped, but engine-destroy drives only the close path (lsquic_stream_destroy → on_close), so they don't fire during the sweep.
Extended reasoning...

Overview

The PR fixes a heap-use-after-free at Worker VM teardown when a node:quic endpoint, session, and stream are all live. Heap::lastChanceToFinalize sweeps the three wrapper types in unspecified order; the previous QuicStream::finalize/QuicSession::finalize were empty, so their boxes dropped while lsquic still held raw ctx pointers to them. When QuicEndpoint::finalize then ran lsquic_engine_destroy, lsquic's force-close path called back through nq_on_stream_close/nq_on_conn_closed into freed memory. The fix has three parts: (1) each wrapper's finalize nulls its lsquic ctx before dropping; (2) QuicEndpoint::detach_for_finalize clears the raw-pointer session registries and nulls vtable.owner so owner-keyed callbacks short-circuit; (3) the vtable's on_stream_close/on_conn_closed are swapped for detached variants that never follow back-pointers into possibly-swept siblings. A subprocess test spawns Workers with 12 live sessions each (pushing wrappers past the 8-cell IsoSubspace lower tier so MarkedBlocks sweep before the endpoint's PreciseAllocation) and terminates them; ASAN debug fails 3/3 without the fix and passes 5/5 with it.

Security risks

None introduced. This is a memory-safety hardening of an existing teardown path; no new user-facing surface, no input parsing, no auth/crypto changes. The change strictly narrows what the lsquic callbacks can reach during VM teardown.

Level of scrutiny

High. This is native FFI lifetime management at the intersection of JSC's finalize-order sweep, lsquic's engine-destroy callback graph, and raw *mut back-pointers between three wrapper types — the exact shape REVIEW.md flags as the most-blocked category. The reasoning is subtle (e.g. the detached callbacks are needed in addition to the per-wrapper ctx-nulling because a not-yet-swept stream's ctx is still valid but its session back-pointer may point at a freed box). The PR description and detach_for_finalize doc comment lay out the invariant clearly, and I verified the "non-null ⇒ live" claim against on_conn_closed (session.rs:2454), on_stream_close (stream.rs), and both teardown() paths — but a maintainer who owns this module should confirm the callback set that lsquic_engine_destroy can fire is fully covered by the two swapped slots plus the owner-null.

Other factors

My two prior inline nits (surface stderr in the assertion; comment the sleep) are resolved — the latter by rewriting the fixture to be deterministic (await an "up" message, then terminate) rather than sleeping. The comment-cop bot flagged the detach_for_finalize doc comment length; the author's response (it's the single place the cross-type invariant is stated, referenced by one-line SAFETY comments elsewhere) is reasonable and the thread is resolved. Fails-without/passes-with evidence under ASAN is included. No outstanding unaddressed reviewer comments.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-25, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
…rker-finalizer-uaf

# Conflicts:
#	src/runtime/node/quic/session.rs
#	src/runtime/node/quic/stream.rs
@robobun robobun reopened this Sep 17, 2026
@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

The walkthrough above raises no concerns and there are no open review threads. The PR is up to date with main at 971e764, and the same five files are in the diff. CI on the merge commit is running. Nothing to change on this side.

Comment thread src/runtime/node/quic/stream.rs

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/node/quic/ffi.rs — nit: Maintainers auditing the one unsafe deref every node:quic callback goes through now read a liveness argument this PR itself shows is false at VM teardown. src/runtime/node/quic/ffi.rs:13-17 still says a non-null ctx is live because "the owning JS wrapper keeps the allocation alive until after the engine that could call us is destroyed", and that only teardown clears the ctx. After this change the load-bearing guarantee is different: QuicSession::finalize / QuicStream::finalize null the lsquic ctx before the box drops, and detach_for_finalize nulls vtable.owner. Fix: restate the contract so it names finalize (and the nulled owner) as the reason a non-null ctx/owner is live, since REVIEW.md requires SAFETY contracts to be accurate.

    Extended reasoning...

    ffi.rs:3 calls this comment "the single audited deref for every node:quic callback"; every lsquic_callback! body and the two new detached callbacks (session.rs:2365, stream.rs:597) cite it as their SAFETY justification. ffi.rs:13-14 says "teardown clears the conn/stream ctx" is the only way a NULL arrives; ffi.rs:15-17 says a non-null ctx is live because the JS wrapper outlives the engine. The PR's own description shows lastChanceToFinalize frees session/stream boxes before QuicEndpoint::finalize runs lsquic_engine_destroy, so the wrapper does not outlive the engine there. The new invariant is enforced at session.rs:2374-2382 and stream.rs:606-612 (finalize nulls the ctx) plus endpoint.rs:2636-2642 (owner nulled, so ctx_ref:: sees NULL for owner-keyed callbacks). None of that is mentioned in the contract; the new SAFETY comments point at QuicEndpoint::detach_for_finalize instead, so the audit point and the real reasoning have diverged. A future change that drops the finalize ctx-null (e.g. treating the empty-bodied finalize as dead code, as it was on the base) would…

    Verification: nit. Triggering condition: any maintainer reading the audited contract that every lsquic_callback! body and both new detached callbacks rely on. The PR does not touch src/runtime/node/quic/ffi.rs; its contract comment still reads (ffi.rs:13-17, identical to base) "The only other value the shim can pass is NULL — teardown clears the conn/stream ctx ... A non-null ctx is a live T: the…

  • 🟡 test/js/node/quic/quic-endpoint.test.ts:294 — nit: CI maintainers get a new test that needs a 30000ms per-test timeout because the fixture takes ~9.5s under the ASAN debug build. test/CLAUDE.md says "Do not set a timeout on tests" and REVIEW.md says to shrink the workload rather than raise the timeout. Fix: bring the fixture under the default budget so the 30000 argument at quic-endpoint.test.ts:294 can be dropped, e.g. one round instead of two in quic-worker-terminate-fixture.ts:59 and the smallest session count that still leaves the IsoSubspace lower tier, while keeping the UAF repro failing on the unfixed ASAN build.

    Extended reasoning...

    The PR's own evidence lists the new test at 9509.94ms on the ASAN debug run and 185.98ms on release. The root CLAUDE.md budget is roughly 1s per test with debug+ASAN 10-100x slower, and test/CLAUDE.md has a "No timeouts" section saying Bun already has timeouts. The fixture at test/js/node/quic/quic-worker-terminate-fixture.ts:59 runs two Worker rounds, each opening 12 client sessions (24 with the server side) with a 64KiB bidi stream, then terminate(). The test passes 30000 as the third argument at test/js/node/quic/quic-endpoint.test.ts:294, which is what keeps the ASAN run from timing out at the default 5000ms. The file already has one such timeout at line 129, so this is precedent, not new practice, but it still adds a second slow serial test to the file. Consequence is a slower ASAN lane and a test whose pass depends on an explicit timeout rather than on fitting the budget; behaviour on the base branch is unaffected. A fixer should shrink rounds or sessions while confirming the unfixed ASAN build still reports heap-use-after-free.

    Verification: nit. Triggering condition: every CI run of this file on the debug/ASAN lane, where the new test only passes because of the raised per-test timeout. Verified in the diff: test/js/node/quic/quic-endpoint.test.ts:294 ends the new test with }, 30000);, and the file has no setDefaultTimeout, so this is an explicit per-test timeout raise. /home/claude/bun/test/CLAUDE.md:118-120 states "### No…

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

Code review found no new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

Still open from earlier reviews (1):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

QuicStream::create stored the lsquic stream pointer before the
ArrayBuffer allocations. If one failed, the wrapper kept a pointer to a
stream lsquic still owned with a null ctx, and the finalizer would call
set_ctx on it after lsquic freed it. bind_raw already sets raw once the
allocations succeed.
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

The open finding on stream.rs was valid: QuicStream::create stored the lsquic pointer before the fallible ArrayBuffer allocations, so a failed allocation left an orphan wrapper whose new finalizer would write a null ctx into a stream lsquic had already freed. 1bc6f1e leaves raw null until bind_raw, which already sets it after the allocations succeed. All 21 tests in test/js/node/quic/ pass on the debug+ASAN build.

Comment thread src/runtime/node/quic/stream.rs Outdated
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

b1a58e3 trims the comment above the QuicStream literal to one line. No code change. The coderabbit note is the same walkthrough as before and raises nothing new. No review threads are open.

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

I reviewed the latest push and found no new bugs; the one item still open from the previous review (QuicStream raw set before the fallible allocations) is addressed by leaving raw null until bind_raw. Because the fix relies on GC-sweep-order reasoning through unsafe FFI ctx pointers, a human look would still be worthwhile.

What was reviewed:

  • All three finalize orderings (endpoint first, session first, stream first): each path either nulls the lsquic ctx before the box drops or hits a detached callback that only touches the wrapper's own raw/conn, never a back-pointer.
  • Owner-keyed vtable callbacks (on_mini_conn_failed, packets_out, SSL_CTX lookups) go through ctx_ref, so the nulled vtable.owner short-circuits them; push_event from the non-detached conn callbacks only appends to the session's own vec.
  • Confirmed the inherent finalize methods take precedence over the JsFinalize default in the generated thunks, and bind_raw still sets raw after the ArrayBuffer allocations succeed.
  • The fixture awaits only message/error; a worker that exits without posting would hit the 30s timeout rather than fail fast (minor, not blocking).
Extended reasoning...

Overview

The PR touches three unsafe-heavy files in src/runtime/node/quic/ (endpoint.rs, session.rs, stream.rs) plus a new subprocess test and fixture. It adds QuicEndpoint::detach_for_finalize (clears the raw *mut QuicSession registries, nulls vtable.owner, swaps on_stream_close/on_conn_closed to detached variants), and new QuicSession::finalize / QuicStream::finalize that null lsquic's ctx pointer before the Box drops. QuicStream::create now initializes raw to null and relies on the pre-existing bind_raw call at the end of create to set it after the fallible ArrayBuffer allocations, which closes the earlier finding about an orphaned wrapper pointing at a stream lsquic no longer associates with it.

Security risks

No user-input parsing, auth, or crypto surface is changed. The risk class is memory safety at the lsquic FFI boundary: a wrong liveness invariant here is a use-after-free reachable by any Worker that uses node:quic and terminates. I traced the three sweep orderings: (1) endpoint swept first: lsquic_engine_destroy drives the detached callbacks, which only null the still-live wrapper's own raw/conn, and the later session/stream finalizers see null and no-op; (2) session swept first: its finalizer nulls the conn ctx so the shim's null check skips every conn-keyed callback, and the stream's detached on_close does not follow session; (3) stream swept first: its finalizer nulls the stream ctx, and the shim's if (h) guard skips on_stream_close. Owner-keyed callbacks go through the lsquic_callback! macro's ctx_ref, which returns the default on a null owner. push_event only appends to the session's own events Vec, so a non-detached conn callback on a live session cannot reach a freed stream or endpoint synchronously. There are no Drop impls on these types that could follow back-pointers.

Level of scrutiny

High. This is unsafe Rust whose correctness depends on JSC's lastChanceToFinalize sweep order and on an invariant ("non-null raw/conn means the lsquic object is live") that is enforced only by convention across on_close, on_conn_closed, teardown, and now the two finalizers. The reasoning holds as far as I can trace it, but the codegen finalize thunk resolution (inherent method winning over the JsFinalize trait default) and the vendored lsquic destroy path (which I could not read in this shallow checkout, so I relied on the shim's null guards) are exactly the kind of thing a maintainer familiar with the QUIC subsystem should confirm. The automated approval bar for complex memory-safety changes is not met.

Other factors

The test is a proper spawned fixture that drains both pipes concurrently and asserts a combined {stdout, stderr, exitCode}, addressing the earlier inline comment. The 30s per-test timeout is on the high side for the suite's ~1s guidance, though the PR states a run takes roughly 10s under debug+ASAN and the workload (12 sessions x 2 rounds) is what makes the sweep order deterministic. The fixture's readiness promise only rejects on error, so a worker that dies without posting up would surface as a timeout rather than an immediate failure; this is a diagnostics nit, not a correctness problem. No CODEOWNERS entry covers the changed paths. All github-actions and coderabbit threads were resolved by the author or a non-author, and the author's commits after my prior review plausibly addressed the open finding.

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

c14090c takes the diagnostics note from the last review: the fixture now rejects on the worker exit event, so a worker that dies before it posts up fails at once instead of at the 30s timeout. Test only.

The CI failures on b1a58e3 (build 117381) are all on lanes the diff does not touch: six tests that passed on retry and a Windows aarch64 step that failed outside the runner. The QUIC suite passed on every lane that ran it.

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

I reviewed the latest push and didn't find any new bugs; the raw-binding change from the earlier thread is now in place (QuicStream::create leaves raw null until bind_raw), but since this is unsafe lifetime handling at VM teardown a human look at the lsquic-side assumptions would still be worthwhile.

What was reviewed:

  • Traced each lastChanceToFinalize ordering (endpoint first, session first, stream first, session before its own streams) through the new finalizers and the on_close_detached / on_closed_detached swaps — each path ends with the lsquic ctx nulled before the box it pointed at is freed.
  • Checked that conn/raw are assigned only on the live-object paths (after the fallible allocations in create, in bind_conn/bind_raw, at connect) and cleared in teardown/on_close/on_conn_closed, so the finalizers' set_ctx(null) never targets a freed lsquic object.
  • Confirmed the registries cleared by detach_for_finalize hold non-owning *mut QuicSession (ProvisionalSession owns only a dcid Vec), and that every owner-keyed vtable callback goes through lsquic_callback!/ctx_ref, so the nulled owner returns the default; on_new_stream resolves its session via the conn ctx instead, which the session finalizer also nulls.
  • Not verified from source: lsquic is not vendored in this checkout, so which stream_if callbacks lsquic_engine_destroy can drive beyond on_close/on_conn_closed (e.g. on_hsk_done from ci_destroy) rests on the PR's description rather than on the destroy path itself.
Extended reasoning...

Overview

The PR touches three native files under src/runtime/node/quic/ (endpoint.rs, session.rs, stream.rs) plus one test and one fixture. The native change is small (~75 lines): QuicEndpoint::finalize now calls a new detach_for_finalize (clears the four *mut QuicSession registries, nulls vtable.owner, swaps on_stream_close/on_conn_closed to variants that touch only the wrapper's own lsquic handle) before release_native runs lsquic_engine_destroy; QuicSession and QuicStream gain inherent finalize(self: Box<Self>) methods that null lsquic's conn/stream ctx when the handle is still non-null, shadowing the blanket JsFinalize drop; and QuicStream::create now leaves raw null until bind_raw, which resolves the allocation-failure orphan raised in the previous review. The test spawns a fixture that twice builds a Worker with a listening endpoint, 12 client sessions and 12 64 KiB streams, then terminate()s it.

Security risks

No injection, auth, or data-exposure surface is touched. The risk class is memory safety: the change exists to remove a heap-use-after-free at Worker teardown, and a mistake in the invariants would be a different UAF (e.g. lsquic_conn_set_ctx on a conn lsquic already freed). I checked that conn is set only at QuicSession::create (after the fallible ArrayBuffer allocations), bind_conn, and the connect success path, and cleared in teardown (which also nulls the ctx) and on_conn_closed (where the shim has already nulled lsquic's copy); raw is set only in bind_raw and cleared in teardown, on_stream_close, and the new on_close_detached. That makes "non-null handle means lsquic still owns a live object" hold for the finalizers. The shim reads the vtable pointer out of the front of each ctx, so nulling the ctx (rather than the vtable slot) is the correct way to make a late callback a no-op. The registries are non-owning; ProvisionalSession owns only a dcid: Vec<u8> and pending_verneg holds a pointer plus a CID array, so Vec::clear frees nothing lsquic or JSC still references. There are no Drop impls on the three wrappers, so the box drop after finalize cannot re-enter lsquic.

Level of scrutiny

High. This is unsafe Rust across an FFI boundary in a finalizer that runs during VM::~VM, where JS-side roots (Strong, this_value) no longer order destruction. The four teardown orders I traced all end with the relevant ctx nulled before its box is freed, and the detached callbacks deliberately avoid the session/endpoint back-pointers that could be dangling. Two things keep this from being an approve: (1) lsquic itself is not in this checkout, so I could not confirm from ietf_full_conn_ci_destroy/lsquic_stream_destroy that on_close and on_conn_closed are the only stream_if callbacks the destroy path drives — the non-swapped callbacks (on_hsk_done, on_conncloseframe, on_stream_reset, ...) would still be safe against a freed wrapper because its ctx is nulled, but against a live wrapper they still follow the session/endpoint back-pointers, so if any of them can fire from lsquic_engine_destroy the same reasoning that motivated the two swaps applies; (2) I did not build and run the fixture in this session. A maintainer who knows the lsquic destroy path can settle (1) quickly.

Other factors

The earlier open finding (raw bound before the fallible allocations) is addressed in the current code, and the prior bot-thread resolutions by the author are consistent with the diff rather than contradicted by it. The PR's description claim that on_new_stream short-circuits via ctx_ref on the nulled owner is slightly inaccurate — on_new_stream ignores the owner and resolves the session from lsquic_conn_get_ctx — but that lookup is nulled by QuicSession::finalize/teardown and the callback cannot fire during lsquic_engine_destroy, so it is a doc nit rather than a bug. The test asserts `{stdout, s

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the two open points in the last review. The PR body said on_new_stream short-circuits on the nulled owner. It does not use the owner. It resolves the session through lsquic_conn_get_ctx, which QuicSession::finalize nulls. I corrected that sentence.

lsquic is vendored at vendor/lsquic. From lsquic_engine_destroy, destroy_conn (lsquic_engine.c:994-1014) calls on_mini_conn_failed, then ci_destroy. ietf_full_conn_ci_destroy (lsquic_full_conn_ietf.c:3202) calls lsquic_stream_destroy per stream, which calls on_close (lsquic_stream.c:663), then on_conn_closed (line 3251). on_hsk_done is reached only from the crypto stream handshake (lsquic_enc_sess_ietf.c:2044-2050), never from destroy. The PR notes now list this.

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.

2 participants