ClientQUIC: a send racing teardown leaks a stream handle — hangs MsQuicRegistrationClose at exit, and intermittently aborts a worker thread #9
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#9
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Summary
Follow-up to #7 / #8. That fix is good — #7's exact reproducer now passes 0/25
on
master(9a5cab8), and itsMsQuicConnectionClose←~ClientQUICbacktraceis gone. But a related fault survives, and it shows up when a client sends on a
stream while the connection is being torn down — the shape any long-lived
client with periodic acks or keepalives has.
Two symptoms, which I believe are one cause:
blocks forever in
MsQuicRegistrationClose.came from).
quic_bugcheckon an msquic worker thread, not on the threadrunning the destructor.
Impact
Any client that reconnects and sends periodically. The reconnect loop itself is
fine now; it is the concurrent send that reintroduces the problem. In the ingress
client this came from, an ack timer sends every 50 ms for the life of a
connection, so every reconnect races a send against teardown.
The hang is arguably the worse of the two: the work completes, the process just
never exits, so a supervisor sees a live-but-wedged process rather than a crash
loop.
What is different from #7
#7's reproducer opens server-initiated streams and parks readers in
RecieveSync. That is fixed. This one adds one thing to it: a client-openedbidirectional stream with a thread sending on it across the teardown.
The server must drain that stream, or
SendSyncblocks on flow control and thehang is the reproducer's own fault rather than a bug — the version below drains
it, and still fails.
Reproducer
tests/ShouldSurviveConnectionChurn/main.cpp, registered withcfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg });Over a longer run the abort appears too (1 of 25). Neither symptom reproduces
under
gdb— 15/15 clean — socoredumpctl, orkill -ABRTon the hungprocess, is the practical way to get a stack.
Stacks
Hang —
kill -ABRTon a process stuck ~12 s after the loop finished:Abort — from the downstream client, same reproducer shape:
while the app thread sits in
std::thread::join.Diagnosis
The hang stack is the solid part.
CxPlatRundownReleaseAndWaitinsideMsQuicRegistrationClosemeans the registration's rundown never reaches zero:an msquic handle was never closed.
QUICStream::Stoponly initiates ashutdown, and the actual
StreamClosehappens inFinalizeCloseoff theSHUTDOWN_COMPLETEcallback — so a stream whoseSHUTDOWN_COMPLETEneverarrives never closes its handle, and the registration can never run down.
#8added exactly the right guard for this —~ClientQUICnow drains a sharedStreamRegistrybefore closing the connection — but the drain is bounded at5 s and then proceeds anyway:
That comment is about not corrupting memory when the wait gives up, which it
achieves. It does not address what happens to the stream that never finalised:
its handle stays open. That would explain both symptoms from one cause —
what the worker thread trips over while draining that connection's operations
(the abort); and
exit()blocks forever (the hang).
The part I am less sure of is why
SHUTDOWN_COMPLETEgoes missing when a sendis in flight, and whether the right fix is to make the drain unbounded, to force
StreamCloseon the streams still outstanding when it expires, or to stopaccepting sends once teardown has begun. I have not tried to fix it — flagging
it because it is shared infrastructure and I did not want to guess at the right
change.
Notes
Found while building a long-lived QUIC ingress client (the same one behind #7).
It is not blocking there — that client is gaining durable cursors, so an abort is
a restart rather than data loss — but it wants a deliberate fix rather than being
worked around downstream.