Skip to content

warp: let the thread manager own the connections (#1126) - #1128

Open
kazu-yamamoto wants to merge 9 commits into
yesodweb:masterfrom
kazu-yamamoto:thread-manager-owns-connections
Open

kazu-yamamoto wants to merge 9 commits into
yesodweb:masterfrom
kazu-yamamoto:thread-manager-owns-connections

Conversation

@kazu-yamamoto

Copy link
Copy Markdown
Contributor

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

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 whose bound first allowed that time-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.ThreadManager is what owns threads here and in the http2 library, and stopAfter is 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). countManaged counts both.

That also means one thing counts the connections and owns them, so warp's Counter is now a wrapper over the manager. getOpenConnectionCounter and getCount are unchanged.

settingsFork is still what forks, which forkManagedUnmask would not have allowed -- it forks with forkIOWithUnmask itself. The reporter's workaround sets settingsFork, so ignoring it was not an option.

Needs time-manager 0.4.1, in this PR. The second commit corrects forkManaged's haddock, which said the thread is registered before it starts.

The new test fails without the stopAfter.

kazu-yamamoto and others added 4 commits October 9, 2026 10:19
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>
@kazu-yamamoto

Copy link
Copy Markdown
Contributor Author

@Vlix http2 and quic also wait for this PR to be merged.

@kazu-yamamoto

Copy link
Copy Markdown
Contributor Author

I tried the branch against two cases and one of them does not hold.

stopAfter does not reach a reservation that has not been taken

stopAfterWithResult empties var and kills what was in it. It does not
look at rvar, so a thread whose place is still being kept is not killed,
and when it does start it registers itself into the now-empty map and runs
on with nobody owning it.

Against System.ThreadManager alone:

import Control.Concurrent
import Control.Concurrent.STM
import System.ThreadManager
import qualified System.TimeManager as T
import System.Timeout (timeout)

main :: IO ()
main = do
    tm <- newThreadManager T.defaultManager
    rsv <- reserveManaged tm
    survived <- newEmptyMVar
    -- Stands in for a thread settingsFork has made but not yet scheduled.
    _ <- forkIO $ do
        threadDelay 300000
        takeReservation tm rsv $ threadDelay 300000 >> putMVar survived ()
    atomically (countManaged tm) >>= \n -> putStrLn $ "before: " ++ show n
    stopAfter tm (return ()) (const (return ()))
    atomically (countManaged tm) >>= \n -> putStrLn $ "after:  " ++ show n
    r <- timeout 2000000 (takeMVar survived)
    putStrLn $ "ran to the end anyway: " ++ show (r == Just ())
before: 1
after:  1
ran to the end anyway: True

(ghc -threaded -i<path>/time-manager -XStrict -XStrictData.)

Warp reaches it. Added to GracefulShutdownSpec, this fails on the branch:

    it "kills a connection forked but not yet scheduled" $ do
        servingNow <- newEmptyMVar
        let app _ respond = do
                putMVar servingNow ()
                threadDelay 10_000_000
                respond $ responseLBS status200 [] ""
            set =
                setFork
                    (\io -> void $ forkIOWithUnmask $ \unmask -> threadDelay 300_000 >> io unmask)
                    defaultSettings
        bracket openFreePort (close . snd) $ \(testPort, sock) -> do
            server <- forkIO $ runSettingsSocket set sock app
            client <- openConnection testPort
            sendAll client "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
            threadDelay 100_000
            killThread server
            timeout 2_000_000 (takeMVar servingNow) `shouldReturn` Nothing
            close client
expected: Nothing
 but got: Just ()

The application began serving after the server had been killed, which is
#1126 again. A settingsFork that does not schedule at once is what the
reporter's workaround sets, so this is not a contrived path.

With warp's own fork the window is one scheduling quantum. Having the
application record whether it started before or after the kill, 40 attempts
per delay: 2-10 of 40 at a 0us delay (it varies between runs), 0 of 40 at
50us and above. Read the first figure as an upper bound -- some of those
may have started between setting the flag and throwTo arriving. The
deterministic case above is the one I would trust.

A thought on the fix: having stopAfter wait for every reservation to be
taken would hang on a reservation that is never taken, so marking the
manager so that a thread registering after the stop is killed on arrival
looks better.

A reservation that is never taken

At the API level it never clears, so the manager never reaches zero:

    tm <- newThreadManager T.defaultManager
    _ <- reserveManaged tm
    timeout 500000 (waitUntilAllGone tm) >>= print   -- Nothing

Warp is not hurt by it, which I checked rather than assumed: with a
settingsFork that throws, the exception leaves acceptLoop, stopAfter
catches and rethrows it, gracefulShutdown is skipped, and the server ends
with the exception rather than hanging. So this is only about how easy the
API is to hold wrongly -- perhaps worth a line in reserveManaged's
haddock, since reserve and the fork that takes it are two statements with
nothing tying them together.

Everything else in warp:test:spec's graceful shutdown group passes on the
branch. Building it needed --allow-newer=http2:time-manager, since
http2 on Hackage still has <0.4.

kazu-yamamoto and others added 5 commits October 17, 2026 16:48
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>
@kazu-yamamoto

Copy link
Copy Markdown
Contributor Author

@Vlix Apologies for the noise. The verification using Claude Code is complete, and it is safe to use from http2 and quic. Now, it’s time for a human review.

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.

Async exception handling with warp > 3.4.9 -> 3.4.16

1 participant