{- | Async-safe release for a claimed __in-flight slot__.

Several places in the proxy collapse duplicate concurrent work onto a single
execution: the metadata cache fronts one upstream fetch per @(source, package)@
("Ecluse.Core.Server.Cache"), and the credential refresher mints at most one token
at a time ("Ecluse.Core.Credential.Refresh"). Each does it by atomically __claiming a
slot__ -- installing an in-flight marker, or setting a flag -- so a second caller finds
the claim and waits (or serves a still-valid value) rather than launching its own run.

A claimed slot carries a sharp obligation: once claimed it must be __released on
every exit__, or the slot wedges. A naive @claim; run; free@ leaks the slot if the
claiming thread is hit by an asynchronous exception -- a request timeout, a killed
handler thread -- in the window between the claim and the run that frees it: a follower
waiting on the slot parks forever, and a later caller blocked behind it never proceeds,
until the process restarts. That is one shared hazard, found and fixed independently in
both consumers, which is why the release discipline lives here once.

'guardInFlight' is that discipline. The caller claims its slot in a single masked 'STM'
transaction and then, with __no interruptible step in between__, hands the leader's run
to 'guardInFlight'. It runs the body and guarantees the slot is released on __every
exit__ -- normal completion, a synchronous failure, or an asynchronous exception anywhere
from the claim onward, including the claim → runner handoff -- and that any follower
waiting on the slot's result is handed the orphaning error rather than left to park. The
body runs under the caller's @restore@ so it stays cancellable; the release and the
waiter hand-off run masked, so the tail cannot itself be interrupted.

What a slot /is/, who waits on it, and how a follower receives a result stay with each
consumer: the cache awaits a result promise; the refresher re-decides against the freed
flag. Only this claim-release discipline is shared.
-}
module Ecluse.Core.InFlight (
    guardInFlight,
) where

import UnliftIO.Exception (finally, withException)

{- | Run a leader's @body@ with the guarantee that its already-claimed in-flight slot is
released on every exit, closing the orphan window.

Call it from inside the same 'UnliftIO.Exception.mask' that committed the claim, with no
interruptible action between the claim and this call, passing that mask's @restore@. The
body runs under @restore@ so it stays cancellable; on any exit the slot is released, and
on a failure the orphaning exception is first handed to any waiting follower -- both run
masked, so the release cannot be orphaned in turn.
-}
guardInFlight ::
    -- | The enclosing mask's @restore@, applied to the body so it stays interruptible.
    (IO a -> IO a) ->
    {- | Run with the orphaning failure before the slot is released, to hand it to a
    follower waiting on the slot's result (the cache fills its result promise so the
    follower unblocks with the error). A consumer whose waiters instead re-decide
    against the freed slot passes a no-op.
    -}
    (SomeException -> IO ()) ->
    {- | Free the claimed slot. Runs on every exit: a normal return, a synchronous
    failure, or an asynchronous exception.
    -}
    IO () ->
    -- | The leader's run, executed under @restore@.
    IO a ->
    IO a
guardInFlight :: forall a.
(IO a -> IO a) -> (SomeException -> IO ()) -> IO () -> IO a -> IO a
guardInFlight IO a -> IO a
restore SomeException -> IO ()
onOrphan IO ()
releaseSlot IO a
body =
    (IO a -> IO a
restore IO a
body IO a -> (SomeException -> IO ()) -> IO a
forall (m :: * -> *) e a b.
(MonadUnliftIO m, Exception e) =>
m a -> (e -> m b) -> m a
`withException` SomeException -> IO ()
onOrphan) IO a -> IO () -> IO a
forall (m :: * -> *) a b. MonadUnliftIO m => m a -> m b -> m a
`finally` IO ()
releaseSlot