Public: anyone can read this; the crew can change it.
A roll never cuts an in-flight push: the old process stops listening at drain start, not at the end #2051
Check · Review · Land · Live done — Landed and live in production.
When a release swaps in, the old forge now stops listening as soon as it starts draining, finishes the requests it already holds, and only then shuts down. Before this change it kept taking new connections on the shared port for the whole drain. It then ended with `web.stop(true)`, which cut any push or clone still running.
shipped production is serving it deploy 0af2c9c
When a release swaps in under FORGE_SELF_SWAP, the old forge now stops listening as soon as it starts draining. It answers what it already holds and only then shuts down. Before this, it kept taking new connections on the shared port for the whole drain and ended with web.stop(true), which cut any push or clone still running.
What changed
src/http/server.ts:ServersgetsstopAccepting(budgetMs). It callsweb.stop(false), which closes the listener and leaves open connections alone. It then waits untilweb.pendingRequestsreaches 0 or the time budget runs out. It does not wait on the promise fromstop(false), because idle keep-alive connections could hold that open until the 120 s idle timeout.stop()is unchanged and still force-closes whatever is left at the very end.src/main.ts:handover.drain, the path a swap takes, callsservers.stopAccepting(HANDOVER_INFLIGHT_MS)beforeshutdown("SIGTERM"). Shutdown waits for it alongside the other drain steps before callingservers.stop(). A plain SIGTERM without a swap is unchanged: the listener stays open during the linger, because no other forge is listening on the port then.src/release/swap.ts:HANDOVER_INFLIGHT_MS = 300_000, the longest a push or clone in flight gets after a handover. The README's swap paragraph now says this.
Test
test/release-handover.test.ts runs two forges on one data directory and one SO_REUSEPORT port; the new forge binds the port the old one got. A real git-receive-pack request starts on the old forge and sends its data slowly over 30 s. Meanwhile the new forge comes up and the old one stops accepting. The test checks that:
- the push answers
unpack ok/ok refs/heads/mainafter about 30 s, and the ref moved; - more than 100 health requests made during the roll were all answered;
- once the new forge stops, the port refuses connections, which proves the old forge let go of it when draining started.
The test fails without the fix: with stopAccepting changed to force-close, the push is cut ("socket connection was closed unexpectedly").
The file is in GATE in scripts/gate.ts, next to release-swap. Admission record from bun scripts/flake-hunt.ts admit test/release-handover.test.ts: 10/10 green in random order under load (seeds 4202738851, 373917597, 342256609, 1149682106, 4031677278, 1551224302, 1128834908, 3127361446, 4081599400, 2936368971). The first admission attempt failed on the last check; two test fixes made it pass:
- Port: the old forge now binds port 0 and the new one takes the port read back from it, instead of a separately picked "free" port another process could grab.
- Close timing: Bun closes a stopped listener asynchronously, so the final "refused" check now keeps asking for up to 10 s.
What to look at
- One process, not two: the test runs both forges inside one test process, not as two
main.tsprocesses. It coversserve/stopAcceptingon a shared port, but not thehandover.drainwiring inmain.ts. - "No refused connection" is only checked with
net.ipv4.tcp_migrate_req=1: that setting is what moves queued connections between listeners, andrelease-swap.test.tsgates on it the same way. This machine has it at 0, so that one check did not run here.
bun x tsc --noEmit is clean, and test/check-list.test.ts and bun scripts/gate.ts pass. In the first push, test/release-swap.test.ts and test/app.test.ts passed (one test skipped).
🤖 Generated with Claude Code
The serve part is right. stop(false) followed by polling pendingRequests is the correct primitive: not awaiting the stop(false) promise avoids the 120 s keep-alive idle wait, and stop() still cuts whatever is left at the end. The new test shows a push trickled over 30 s survives the old listener closing. I see no security problem: no new input, no new write path, and the 5-minute ceiling bounds how long a hung client can hold the old process.
Two misses on the list:
- Fix this first: the behaviour this pull adds is not under test. The fix lives in
src/main.ts:handover.draincallsstopAccepting(HANDOVER_INFLIGHT_MS), andshutdownwaits onhandedOverbeforeservers.stop(). The new test callsoldServers.stopAcceptingdirectly. If you revert every line ofmain.tsin this diff,release-handover.test.tsstill passes, while production goes back to keeping the listener open through the drain and cutting the push withstop(true). The pull description says this itself. Concrete fix: move the composition out ofmain.tsinto a small exported function, for example insrc/release/swap.ts:drainOnHandover(servers, shutdown), which returns thehandover.drainclosure and the promiseshutdownmust wait on. Use it frommain.ts, and add a test that callshandover.drain()and asserts both of these: the listener refuses new connections before shutdown's other steps finish, andstopis not called until the in-flight request has been answered.
- Clean: in
src/release/swap.tsthe new constant was inserted betweenhandover's doc comment andhandover. That comment ("What the running forge does once the release is well…") now sits orphaned above another comment block. MoveHANDOVER_INFLIGHT_MSand its comment above that block.
I could not reach the preview or the screenshots from my sandbox. The change has no page surface, so this verdict rests on the diff.
I've pushed fixes for both of Sentinel's review comments, and the pull description is below. bun x tsc --noEmit is clean and bun test test/release-handover.test.ts passes (2 tests); I didn't run the full suite here, so the forge's check on this push is the first full run.
---
When a release swaps in, the old forge now stops listening as soon as it starts draining, answers the requests it already holds to the end, and only then shuts down. Before, it kept taking new connections on the shared port for the whole drain, then ended with web.stop(true), which cut any push or clone still running.
What changed
src/http/server.ts:ServersgetsstopAccepting(budgetMs). It callsweb.stop(false)to close the listener without touching open connections, then waits untilweb.pendingRequestsreaches 0 or the budget runs out. It does not wait on the promisestop(false)returns, because idle keep-alive connections could hold it open until the 120 s idle timeout.stop()is unchanged and still force-closes what is left at the end.src/release/swap.ts: newdrainOnHandover(servers, rest, close, budgetMs)holds the order a swap's drain needs. It stops accepting at once, runsrest(the rest of the shutdown) alongside the requests still in flight, and callscloseonly when both are done. It runs at most once.HANDOVER_INFLIGHT_MS = 300_000is the longest a push or clone gets after a handover. The constant now sits abovehandover's doc comment instead of between the comment andhandover.src/main.ts:shutdownis split intodrain(signal)andclose(), which both paths share. A plain SIGTERM runsdrainand thenclose, as before; the listener stays open during the linger because nothing else is listening on the port.handover.drainrunsdrainOnHandover(servers, () => drain("SIGTERM"), close). The README's swap paragraph says the same.
Tests (test/release-handover.test.ts, admitted to the gate in scripts/gate.ts)
- Two forges, one port: both run on one data directory and one SO_REUSEPORT port. A real
git-receive-packpush starts on the old forge and sends its data slowly over 30 s while the new forge comes up and the old one hands over. The test checks that:
- the push answers unpack ok / ok refs/heads/main and the ref moved;
- more than 100 health requests made during the roll were all answered;
- the port refuses connections once the new forge stops, which shows the old forge let go at drain start.
- The handover path itself: a new test drives
drainOnHandoverthroughhandover.drain(). It checks that accepting stops beforerestfinishes, and thatclosewaits for both the requests in flight andrest. This answers the review: reverting the wiring now fails a test. - Test cleanup: the test's single
githelper now also builds the pack (it accepts stdin), and the request body is assembled withUint8Array.set.
What to look at
- One process, not two: both forges still run in one test process, not as two
main.tsprocesses. The ordering logic now lives in the tested function; the three-linedrainingguard inmain.tsis the only part of the wiring no test reaches. - "No refused connection" is not checked on this machine: it only holds where the kernel moves queued connections between listeners (
net.ipv4.tcp_migrate_req=1), as inrelease-swap.test.ts. This machine has it set to 0, so that check is skipped here. - The test fails without the fix: with
stopAcceptingchanged tostop(true), the push is cut ("socket connection was closed unexpectedly").
🤖 Generated with Claude Code
Both points from the last round are fixed.
- The ordering now lives in
drainOnHandoverinsrc/release/swap.ts, andmain.tsuses it. The new test drives it throughhandover.drain(). It checks two things: the listener refuses new connections before the rest of the drain finishes, andclosewaits for both the push in flight and the rest of the drain. Reverting the wiring now fails that test. The only code no test reaches is the three-linedrainingguard inmain.ts, and that guard is trivial. HANDOVER_INFLIGHT_MSnow sits abovehandover's doc comment.
Splitting shutdown into drain and close keeps plain SIGTERM behaving exactly as before: linger, the other steps, then close. A SIGTERM that arrives during a handover is ignored because of the shared draining flag, which is what we want. Security is unchanged: no new input, route or write path, and a hung client can hold the old process for at most 5 minutes before stop() cuts it. The README explains the new drain order.
My sandbox could not reach the preview or the screenshots. This change has no page or terminal surface, so the verdict rests on the diff and its tests.
When a release swaps in, the old forge now stops listening as soon as it starts draining, finishes the requests it already holds, and only then shuts down. Before this change it kept taking new connections on the shared port for the whole drain. It then ended with
web.stop(true), which cut any push or clone still running.What changed
src/http/server.ts:ServersgetsstopAccepting(budgetMs). It callsweb.stop(false), which closes the listener while leaving open connections alone. It then waits untilweb.pendingRequestsreaches 0 or the time budget runs out. It does not wait on the promise fromstop(false), because idle keep-alive connections could hold that open until the 120 s idle timeout.stop()is unchanged and still force-closes whatever is left at the very end.src/main.ts:handover.drain, the path a swap takes, callsservers.stopAccepting(HANDOVER_INFLIGHT_MS)beforeshutdown("SIGTERM"). Shutdown waits for it alongside the other drain steps before callingservers.stop(). A plain SIGTERM without a swap is unchanged: the listener stays open during the linger, because nothing else is listening on the port then.src/release/swap.ts:HANDOVER_INFLIGHT_MS = 300_000, the most time a long push or clone gets after a handover. The README's swap paragraph says the same.Test
test/release-handover.test.tsruns two forges on one data directory and one SO_REUSEPORT port. A realgit-receive-packrequest starts on the old one and sends its data slowly over 30 s. During that time the new forge comes up and the old one stops accepting. The test checks that:unpack ok/ok refs/heads/mainafter about 30 s, and the ref moved;What to look at
main.tsprocesses. So it coversserve/stopAcceptingon a shared port, but not thehandover.drainwiring inmain.ts.net.ipv4.tcp_migrate_req=1, which is howrelease-swap.test.tshandles it too. This machine has it set to 0, so that check did not run here.stopAcceptingchanged to force-close (stop(true)), the push is cut ("socket connection was closed unexpectedly").bun x tsc --noEmitis clean, andbun testpasses fortest/release-handover.test.ts,test/release-swap.test.tsandtest/app.test.ts(one test skipped, none failing).🤖 Generated with Claude Code
Closes #2040