Refuse connections once the server starts closing, so its close cannot hang #160

Merged
cruelacid merged 1 commit from worktree-nec-89-destroy-race into main 2026-09-24 22:33:54 +01:00
Owner

Task: NEC-89

NEC-89's e2e hang was the test server's close(), not Obsidian. The close codes added in #157 showed it in soak run 710, which caught the hang twice in 40 iterations. The early disconnect was the server's own shutdown, code 1001 "server restarting", so the test was stuck in await server.close().

How it hung

  1. WsServer.destroy() closes the sockets it has, then waits up to 2 s for each close to be acknowledged.
  2. An upgrade that arrived during that wait was accepted and added to the client map.
  3. The final clear() then dropped it without ever closing it.
  4. The harness's http.close() waits for every connection to end, so it waited on that socket until vitest's 300 s timeout.

The fix. destroy() now marks the server as closing before it touches a socket.

  • A later upgrade gets a 503.
  • A handshake that finishes after that point is closed with destroy()'s own code.
  • Anything still in the client map after the wait is terminated.

Production impact. Small. Production closes the HTTP server before closing sockets and exits after a 10 s drain, so at worst a late socket slowed a deploy's shutdown.

Tests. shutdown-race.test.ts makes the race deterministic by pausing the first client's socket, so destroy() is still waiting when the second client connects.

  • Both tests fail on the unfixed code; one of them hangs exactly as CI did.
  • Each of the three guards makes them pass on its own.

Still to prove. An 80-iteration soak dispatched on this branch. The current rate is about 1 hang in 20 iterations, so 0 hangs in 80 would be about 1.6% likely if nothing had changed.

Changelog

NONE

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q6gkmHZ3pYvsZCCpiA9qe9

Task: NEC-89 NEC-89's e2e hang was the test server's `close()`, not Obsidian. The close codes added in #157 showed it in soak run 710, which caught the hang twice in 40 iterations. The early disconnect was the server's own shutdown, code 1001 "server restarting", so the test was stuck in `await server.close()`. **How it hung** 1. `WsServer.destroy()` closes the sockets it has, then waits up to 2 s for each close to be acknowledged. 2. An upgrade that arrived during that wait was accepted and added to the client map. 3. The final `clear()` then dropped it without ever closing it. 4. The harness's `http.close()` waits for every connection to end, so it waited on that socket until vitest's 300 s timeout. **The fix.** `destroy()` now marks the server as closing before it touches a socket. - A later upgrade gets a 503. - A handshake that finishes after that point is closed with `destroy()`'s own code. - Anything still in the client map after the wait is terminated. **Production impact.** Small. Production closes the HTTP server before closing sockets and exits after a 10 s drain, so at worst a late socket slowed a deploy's shutdown. **Tests.** `shutdown-race.test.ts` makes the race deterministic by pausing the first client's socket, so `destroy()` is still waiting when the second client connects. - Both tests fail on the unfixed code; one of them hangs exactly as CI did. - Each of the three guards makes them pass on its own. **Still to prove.** An 80-iteration soak dispatched on this branch. The current rate is about 1 hang in 20 iterations, so 0 hangs in 80 would be about 1.6% likely if nothing had changed. ## Changelog NONE 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Q6gkmHZ3pYvsZCCpiA9qe9
Refuse connections once the server starts closing, so its close cannot hang
All checks were successful
Release note / release-note (pull_request) Successful in 15s
CI / build (pull_request) Successful in 5m26s
CI / e2e (pull_request) Successful in 4m14s
CI / promote (pull_request) Has been skipped
1370c2d966
NEC-89's e2e hang was never Obsidian. Soak run 710 caught it twice in 40
iterations, and the close codes #157 added showed the early disconnect
was the server's own shutdown (1001 "server restarting"): the test had
moved on to `await server.close()`, and that never finished.

WsServer.destroy() closes the sockets in its client map, then waits up
to 2 s for each close to be acknowledged. An upgrade that landed in the
wait was accepted and added to the map, and the final clear() dropped it
without closing it. The test harness then waits on http.close(), which
waits for every connection, so it waited on that socket until vitest's
300 s timeout. external-edit and repro-runaway close their server the
moment their vaults come up, while the second vault is still connecting,
which is why the hang only ever struck those two files.

destroy() now marks the server closing before it touches a socket. An
upgrade after that gets a 503. One whose handshake finishes after it is
closed with destroy()'s own code, and anything still in the map after
the wait is terminated rather than cleared past.

Production mostly avoided this: createShutdown closes the HTTP server
before closeSockets, and exits after a 10 s drain. At worst a late
socket slowed a deploy's shutdown.

shutdown-race.test.ts makes the race deterministic by pausing the first
client's socket, so its close acknowledgement never arrives and destroy()
sits in its wait while a second client connects. Both tests fail on the
unfixed code, one of them by hanging exactly as CI did. Each of the three
guards passes them alone, so the tests pin the fix as a whole, not
each guard.

The e2e README's reading of the hang as a renderer freeze is corrected,
and kept as the record of how it was wrong.

Task: NEC-89

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6gkmHZ3pYvsZCCpiA9qe9
cruelacid force-pushed worktree-nec-89-destroy-race from 1370c2d966
All checks were successful
Release note / release-note (pull_request) Successful in 15s
CI / build (pull_request) Successful in 5m26s
CI / e2e (pull_request) Successful in 4m14s
CI / promote (pull_request) Has been skipped
to 151bfc729b
All checks were successful
Release note / release-note (pull_request) Successful in 13s
CI / build (pull_request) Successful in 4m40s
CI / e2e (pull_request) Successful in 4m51s
CI / promote (pull_request) Has been skipped
CI / e2e (push) Successful in 4m50s
CI / build (push) Successful in 4m56s
CI / promote (push) Successful in 31s
2026-09-24 22:28:10 +01:00
Compare
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Nectenda/nectenda!160
No description provided.