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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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: Servers gets stopAccepting(budgetMs). It calls web.stop(false), which closes the listener while leaving open connections alone. It then waits until web.pendingRequests reaches 0 or the time budget runs out. It does not wait on the promise from stop(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, calls servers.stopAccepting(HANDOVER_INFLIGHT_MS) before shutdown("SIGTERM"). Shutdown waits for it alongside the other drain steps before calling servers.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.ts runs two forges on one data directory and one SO_REUSEPORT port. A real git-receive-pack request 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:

  • the push answers unpack ok / ok refs/heads/main after 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.

What to look at

  • One process, not two: the test runs both forges inside one test process, not as two main.ts processes. So it covers serve/stopAccepting on a shared port, but not the handover.drain wiring in main.ts.
  • "No refused connection" is checked only where the kernel moves queued connections between listeners: that means net.ipv4.tcp_migrate_req=1, which is how release-swap.test.ts handles it too. This machine has it set to 0, so that check did not run here.
  • The test fails without the fix: with stopAccepting changed to force-close (stop(true)), the push is cut ("socket connection was closed unexpectedly").

bun x tsc --noEmit is clean, and bun test passes for test/release-handover.test.ts, test/release-swap.test.ts and test/app.test.ts (one test skipped, none failing).

🤖 Generated with Claude Code

Closes #2040

commented

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: Servers gets stopAccepting(budgetMs). It calls web.stop(false), which closes the listener and leaves open connections alone. It then waits until web.pendingRequests reaches 0 or the time budget runs out. It does not wait on the promise from stop(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, calls servers.stopAccepting(HANDOVER_INFLIGHT_MS) before shutdown("SIGTERM"). Shutdown waits for it alongside the other drain steps before calling servers.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/main after 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.ts processes. It covers serve/stopAccepting on a shared port, but not the handover.drain wiring in main.ts.
  • "No refused connection" is only checked with net.ipv4.tcp_migrate_req=1: that setting is what moves queued connections between listeners, and release-swap.test.ts gates 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

Changes requested — before the latest push

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:

  1. Fix this first: the behaviour this pull adds is not under test. The fix lives in src/main.ts: handover.drain calls stopAccepting(HANDOVER_INFLIGHT_MS), and shutdown waits on handedOver before servers.stop(). The new test calls oldServers.stopAccepting directly. If you revert every line of main.ts in this diff, release-handover.test.ts still passes, while production goes back to keeping the listener open through the drain and cutting the push with stop(true). The pull description says this itself. Concrete fix: move the composition out of main.ts into a small exported function, for example in src/release/swap.ts: drainOnHandover(servers, shutdown), which returns the handover.drain closure and the promise shutdown must wait on. Use it from main.ts, and add a test that calls handover.drain() and asserts both of these: the listener refuses new connections before shutdown's other steps finish, and stop is not called until the in-flight request has been answered.
  1. Clean: in src/release/swap.ts the new constant was inserted between handover's doc comment and handover. That comment ("What the running forge does once the release is well…") now sits orphaned above another comment block. Move HANDOVER_INFLIGHT_MS and 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.

the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

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: Servers gets stopAccepting(budgetMs). It calls web.stop(false) to close the listener without touching open connections, then waits until web.pendingRequests reaches 0 or the budget runs out. It does not wait on the promise stop(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: new drainOnHandover(servers, rest, close, budgetMs) holds the order a swap's drain needs. It stops accepting at once, runs rest (the rest of the shutdown) alongside the requests still in flight, and calls close only when both are done. It runs at most once. HANDOVER_INFLIGHT_MS = 300_000 is the longest a push or clone gets after a handover. The constant now sits above handover's doc comment instead of between the comment and handover.
  • src/main.ts: shutdown is split into drain(signal) and close(), which both paths share. A plain SIGTERM runs drain and then close, as before; the listener stays open during the linger because nothing else is listening on the port. handover.drain runs drainOnHandover(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-pack push 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 drainOnHandover through handover.drain(). It checks that accepting stops before rest finishes, and that close waits for both the requests in flight and rest. This answers the review: reverting the wiring now fails a test.
  • Test cleanup: the test's single git helper now also builds the pack (it accepts stdin), and the request body is assembled with Uint8Array.set.

What to look at

  • One process, not two: both forges still run in one test process, not as two main.ts processes. The ordering logic now lives in the tested function; the three-line draining guard in main.ts is 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 in release-swap.test.ts. This machine has it set to 0, so that check is skipped here.
  • The test fails without the fix: with stopAccepting changed to stop(true), the push is cut ("socket connection was closed unexpectedly").

🤖 Generated with Claude Code

Approved

Both points from the last round are fixed.

  1. The ordering now lives in drainOnHandover in src/release/swap.ts, and main.ts uses it. The new test drives it through handover.drain(). It checks two things: the listener refuses new connections before the rest of the drain finishes, and close waits 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-line draining guard in main.ts, and that guard is trivial.
  2. HANDOVER_INFLIGHT_MS now sits above handover'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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs