Repository navigation
warp: let the thread manager own the connections (#1126) - #1128
kazu-yamamoto wants to merge 9 commits into
Conversation
Warp forks a thread per connection with settingsFork and nothing else owns them: they are not the children of the thread running runSettings in any sense the runtime knows about. An asynchronous exception to that thread handed the caller its exception back and left the applications in flight running, which is what yesodweb#1126 reports. Until 3.4.10 something did own them, by accident. Warp brackets a time manager with stopManager, which used to run every registered timeout action, and each connection was registered with withHandleKillThread. stopManager became a no-op in time-manager 0.3.0, and 3.4.10 is the version that first allowed that time-manager. Warp's own code did not change. System.ThreadManager is what owns threads here and in the http2 library, but it registers a thread from inside it, and warp counts a connection from the accept loop, before the thread that will serve it has been scheduled: counting later would let a graceful shutdown walk past a connection it had just accepted. So the manager can now be told to keep a place for a thread about to be forked, and the thread takes that place over when it starts. The Counter is a wrapper over the manager, so one thing counts the connections and owns them, and getOpenConnectionCounter and getCount are as they were. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
forkManaged said the thread ID is added to the manager's queue before the thread starts. It is added by the thread, as the first thing it does, and the caller does not wait for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@Vlix |
|
I tried the branch against two cases and one of them does not hold.
|
stopAfter emptied the map of managed threads and killed what was in it, and did not look at the places kept by reserveManaged. A thread whose place was still being kept was not killed, and when it started it registered itself into the now-empty map and ran on with nobody owning it: yesodweb#1126 again, reached by a settingsFork which does not schedule at once, which is what the reporter of that issue sets. So stopping the manager is recorded, and a thread which registers after that is killed on arrival rather than registered. It was forked before the stop and there is no one left to kill it otherwise. The place kept for it is given back on that path too, which leaves only a place that is never taken at all -- said in reserveManaged's haddock, since nothing ties a reservation to the fork that takes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refusing to record a thread because the manager has stopped is a throw from the acquire of a bracket, and two of the forks were not ready for it. forkManagedTimeout had its handler inside the bracket, so the refusal escaped the fork and an http2 server tearing a connection down printed it. The Finally variants had their cleanup in the bracket body, so the cleanup that always runs stopped always running. Both now wrap the bracket rather than sit inside it. Reported by a verification of the previous commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refusing to record a thread was a throw from the acquire of a bracket, so the cleanup of the Finally variants had to sit outside the bracket to run on that path at all. Outside it, the cleanup ran after the thread had left the manager, and waitUntilAllGone could return while one was still going. The refusal is a Nothing now rather than a throw, so the cleanup goes back inside the bracket: it runs however the body ends, including when there is no body to run, and the thread is counted throughout. Reported by a verification of the previous commit, which also exercised the http2 path for the escape that commit fixed: 1 to 3 escapes per 300 connections before it, none after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
stopAfter emptied the map of managed threads before killing them, so between it returning and those threads actually ending the manager reported that it owned nothing. throwTo waits for the exception to be taken, not for the thread to be gone. A caller which frees what those threads were using has nothing to wait on. Emptying the map there is older than this manager. Until 607a826 in the http2 library the threads were held by a manager thread, as the argument of its loop, and stopping it ended that loop and took the map with it; when that thread was replaced by a TVar the same line was written as emptying the map. There is no invariant under it: a thread is taken out of the map by its own bracket as it ends, and what keeps new threads out is the stopped flag. So the map is left alone, the threads take themselves out as they end, and countManaged counts them until they do. A thread which swallows the exception and never ends is counted for ever, which the haddock now says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A thread that has not arrived yet is not told to stop; it is refused when it gets there. stopAfter said only the first half of that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@Vlix Apologies for the noise. The verification using Claude Code is complete, and it is safe to use from |
Fixes #1126 on master. #1127 is the same bug fixed for a 3.4 release, by other means.
Warp forks a thread per connection with
settingsFork, and nothing else owns those threads: they are not children of the thread runningrunSettingsin any sense the runtime knows about. An asynchronous exception to that thread handed the caller its exception back and left the applications in flight running.Until 3.4.10 something did own them, by accident. Warp brackets a time manager with
stopManager, which used to run every registered timeout action, and each connection was registered withwithHandleKillThread.stopManagerbecame a no-op intime-manager0.3.0, and 3.4.10 is the version whose bound first allowed thattime-manager. Warp's own code did not change between 3.4.9 and 3.4.10, which is why the reporter's bisection landed there.System.ThreadManageris what owns threads here and in the http2 library, andstopAfteris what kills them. It did not fit as it was, for one reason: it registers a thread from inside the thread, and warp counts a connection from the accept loop, before the thread that will serve it has been scheduled. Counting later would let a graceful shutdown walk past a connection it had just accepted -- there is a test for that,waits for a connection accepted just before it stopped accepting. So the manager can be told to keep a place for a thread about to be forked (reserveManaged), and the thread takes that place over when it starts (takeReservation).countManagedcounts both.That also means one thing counts the connections and owns them, so warp's
Counteris now a wrapper over the manager.getOpenConnectionCounterandgetCountare unchanged.settingsForkis still what forks, whichforkManagedUnmaskwould not have allowed -- it forks withforkIOWithUnmaskitself. The reporter's workaround setssettingsFork, so ignoring it was not an option.Needs
time-manager0.4.1, in this PR. The second commit correctsforkManaged's haddock, which said the thread is registered before it starts.The new test fails without the
stopAfter.