"A permit is a promise about hardware. The moment the permit's scope or its lifetime stops matching what the hardware is actually doing, you own a very convincing piece of decoration."
The Gate Was Right and Still Leaked — Twice
The previous lesson put a semaphore of one around the GPU section so two inferences could never stack and double peak memory. The principle was stated correctly: gate the scarce resource. The implementation put the semaphore on the adapter object. Those two sentences are not the same sentence, and the difference cost two separate bugs — both of them the same out-of-memory crash coming back through doors the gate couldn't see.
Leak One: A Second Consumer Outside the Object
The engine grew upscalers — a pixel-space upscaler path and a slow, minutes-long enhancement model. Those run on the same device, but they run from a different route, not through the adapter. The adapter's semaphore was an attribute on the adapter instance, so the upscale path simply never asked for the permit. A generation and a long upscale could sit on the device together, which is the doubled peak memory the whole gate existed to prevent. Worse, two upscales never serialized against each other either — from the gate's point of view they didn't exist.
The fix is small and the lesson is large: hoist the semaphore out of the object and into the process, so every entry point that touches the device shares one permit. The adapter binds to it, the upscale route acquires it around its own thread hand-off, and any future device consumer inherits the discipline by construction instead of by remembering.
Leak Two: Cancelling the Wait Is Not Cancelling the Work
The second leak is subtler and has nothing to do with scope. GPU work runs in a worker thread so the event loop stays responsive; the async side awaits that thread inside the gate. Then a user cancels a generation — the normal flow, cancel, nudge a setting, regenerate. Cancelling the task unwinds the await immediately, which exits the block holding the permit. But the worker thread doesn't know anything happened. It is still mid-step on the device.
So the permit was free while the device was busy. The regenerate then acquired the permit legitimately and started a second inference on a device that already had one running: the exact double-peak path, reached this time not by bypassing the gate but by obeying it.
The Fix: Hold Until the Thread Actually Exits
The repair shields the thread's future from the cancellation, sets a flag the sampler's per-step callback checks, waits for the thread to notice and unwind — absorbing further cancels while it waits — and only then lets the cancellation propagate. The permit is released when the device is free, not when the caller stopped caring. The same treatment goes on the model-load path, because a client disconnecting mid-preload hits the identical race.
The Tax a Process-Wide Singleton Charges
Hoisting to process scope buys correctness and bills you in tests. A concurrency primitive built lazily binds to whatever event loop is running when it's first created, and a test suite that gives each case a fresh loop will hand the second test a gate bound to the first test's dead loop. The answer is an automatic per-test reset of the singleton — a small, deliberate piece of test wiring that exists purely because the thing is now process-scoped. Worth naming, because it's the moment a lot of people quietly revert to instance scope to make the tests pass.