fix(quic): stop ~ClientQUIC aborting the process on teardown #8
No reviewers
Labels
No labels
claude:blocked
claude:done
claude:failed
claude:in-progress
claude:ready
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Catcrafts/Crafter.Network!8
Loading…
Reference in a new issue
No description provided.
Delete branch "claude/issue-7"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
~ClientQUICaborted the process on roughly half of teardowns. The diagnosisin the issue was close but landed on the wrong mechanism, so the fix covers
both what was reported and what was actually crashing.
What was actually aborting
MsQuicConnectionClosewas reachable twice for the same handle:~ClientQUICcalled it unconditionally, andSHUTDOWN_COMPLETEconnection callback also called it wheneverAppCloseInProgresswas clear — which it is for the entire window betweenthe destructor's
ConnectionShutdownand itsConnectionClose.The second call trips
CXPLAT_TEL_ASSERT(!Connection->State.HandleClosed)atthe top of
MsQuicConnectionClose(a liveCXPLAT_FRE_ASSERTin this build),which is the
quic_bugcheck ← MsQuicConnectionClose ← ~ClientQUICstack fromthe report. The callback's
self->connection = nullptrdid not help — thedestructor had already loaded the handle.
impl->connectionis now a claim token, taken under the mutex byClaimConnection(). Exactly one of the two wins and closes. The callback keepsclosing peer-dropped connections so they do not leak while their
ClientQUIClives on.
This also explains the 50 ms-sleep evidence in the issue: sleeping before the
destructor lets the callback finish and null the handle first, so the
destructor re-reads
nullptrand quietly does nothing.The reported stream-ordering race, fixed too
QUICStream::Stoponly initiates a shutdown whose async completion does theactual
StreamClose, so a connection could be closed with streams msquic stillconsidered open.
~ClientQUICnow waits for them, as suggested, via aStreamRegistryincremented where the msquic handler is installed(
OpenStream/ the peer-stream constructor) and decremented inFinalizeClose.The wait is bounded at 5 s — the decrements arrive on msquic worker threads, so
an unbounded wait would turn a dropped peer into a hung destructor. The count
lives in a
shared_ptr<StreamRegistry>rather than behind theClientQUIC*soa stream finalising after the wait gave up decrements a live object instead of
a freed connection.
Instrumenting the destructor showed this path is real but rarer than the double
close: streams were still open at close time on roughly 1 run in 3 (server side,
where the app holds stream wrappers past the connection). On its own it is a
refcount leak rather than the abort.
Also in here
HandshakeIdleTimeoutMswas never set, so it inheritedthe 120 s idle timeout and the constructor's
cv.waitparked for minutes on ahost that never answers. Bounded to 10 s, as the issue's lower-priority note
suggested — kept as its own commit.
~ClientQUICnever runs, so the connection and configuration handles bothleaked — and the connection callback holds the
implthat was about to bedestroyed with the half-built object. The failure path now closes both.
Stop()reads the handle under the lock instead of racing the callback.Testing
tests/ShouldSurviveConnectionChurn— 25 build-up/tear-down cycles with readerthreads parked inside
RecieveSync, the shape a reconnect loop has. Nothingelse in the suite exercised this: every other QUIC test ends in
std::_Exit(0)and so never runs aClientQUICdestructor.The server side of the loop deliberately destroys the connection before
clearing its stream wrappers, which is what exercises the drain wait.
One pre-existing failure
crafter-build testis 18 passed, 1 failed. The failure isShouldSend,the live-interop test against
cloudflare-quic.com:443, which this environmentcannot reach (
curl https://cloudflare-quic.com/times out; the host is not onthe egress allowlist). README already flags it: "The external-interop test
requires outbound UDP/443; if your network blocks it the test will fail."
Verified on
masterin a clean worktree, same box:17 passed, 1 timed out—ShouldSend (60005ms) timeout. On this branch it fails in 10 s withQUIC handshake failed: 0x3e(QUIC_STATUS_CONNECTION_IDLE) instead of hangingfor a minute, which is the handshake bound above doing its job.
Every other test passes, and the wasm32-wasip1 build is green.
Resolves #7
🤖 Generated with Claude Code