Skip to content

Keep the accept loop alive through shutdown, a broken client, and a wedged handle - #1

Merged
sebgod merged 1 commit into
mainfrom
sebastiangodelet/accept-loop-survives-shutdown
Aug 20, 2026
Merged

sebgod merged 1 commit into
mainfrom
sebastiangodelet/accept-loop-survives-shutdown

Conversation

@sebgod

@sebgod sebgod commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Three defects in AcceptLoop, all found by driving the gate hard on Linux (4 cores, 8 gates at a time, under load) while porting it into a PDF viewer. None is Linux-specific; that is only where the timing exposes them.

1. Shutdown killed the process

catch (Exception ex) when (!_stopping) is an exception filter, and a filter that declines does not swallow. Once Dispose has set the flag, the exception from the in-flight connection leaves AcceptLoop and goes unhandled on that thread, taking the whole process with it.

Disposing while a hand-off is in flight is common enough under load that it killed a 400-round harness outright rather than failing a round:

   at System.IO.Pipes.NamedPipeServerStream.WaitForConnection()
   at SharpAstro.AppShell.InstanceGate.AcceptLoop()
   at System.Threading.Thread.StartCallback()

The flag is now read inside the catch, so shutdown is a return and everything else is logged.

2. A client that gave up before writing left the holder deaf

Releasing the connection was gated on IsConnected, which tracks the local handle and is already false by then, so the instance was never disconnected and every later hand-off met a listener that could only throw. Disconnect is unconditional now; it throws when there was nothing to release, and that is what the catch is for.

3. A wedged handle never recovered

A client that connects and vanishes before the accept returns can leave the instance throwing ERROR_PIPE_CLOSING from WaitForConnection for ever, and Disconnect does not clear it:

[Debug] IOException: The pipe is being closed. thrown by WaitForConnection   x111, no recovery
round 1: TryHandOff=False in 7017 ms, dequeued=''

That is a gate any local process can kill permanently. After five consecutive failures the listener is rebuilt, which recovers in ~344 ms:

round 1: TryHandOff=True in 344 ms, dequeued='after' [5 gate errors]

Rebuilding releases and retakes the pipe name, which normally must never happen — but the alternative is an instance that has stopped accepting for good, so a window of microseconds beats a permanent outage. If the name cannot be retaken the loop ends, because this process is then genuinely no longer the holder. A Thread.Sleep(50) bounds the retry to 20/s, so a failing wait costs a delay nobody can perceive rather than a burnt core.

Tests

Two, both cross-platform. Disposing_while_a_hand_off_is_in_flight_does_not_take_the_process_down does not fail as a red test if it regresses — it takes the run with it, which is the point, and is why it loops rather than trying once.

Suite: 22/22, and it got faster (6s to 2s) because the deaf-holder case no longer burns its 5 second timeout.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ExF9yGxB83NgQo6zJwcQMM

…edged handle

Three defects in AcceptLoop, all found by driving the gate hard on Linux (4 cores,
8 gates at a time, under load) while porting it into a PDF viewer. None of them is
Linux-specific; that is only where the timing exposes them.

1. Shutdown killed the process. `catch (Exception ex) when (!_stopping)` is a
   FILTER, and a filter that declines does not swallow: once Dispose has set the
   flag, the exception from the in-flight connection leaves AcceptLoop and goes
   unhandled on that thread, taking the whole process with it. Disposing while a
   hand-off is in flight is common enough under load that it killed a 400-round
   harness outright rather than failing a round. The flag is now read inside the
   catch, so shutdown is a return and everything else is logged.

2. A client that gave up before writing left the holder deaf. Releasing the
   connection was gated on IsConnected, which tracks the LOCAL handle and is
   already false by then, so the instance was never disconnected and every later
   hand-off met a listener that could only throw. Disconnect is unconditional now;
   it throws when there was nothing to release, and that is what the catch is for.

3. A wedged handle never recovered. A client that connects and vanishes before the
   accept returns can leave the instance throwing ERROR_PIPE_CLOSING from
   WaitForConnection for ever, and Disconnect does not clear it -- measured at 111
   consecutive failures with no way out, which is a gate any local process can kill
   permanently. After five consecutive failures the listener is now rebuilt. That
   releases and retakes the pipe NAME, which normally must never happen, but the
   alternative is an instance that has stopped accepting for good; if the name
   cannot be retaken the loop ends, because this process is then genuinely no
   longer the holder. A Thread.Sleep(50) bounds the retry to 20/s so a failing wait
   costs a delay nobody can perceive rather than a burnt core.

Two tests, both cross-platform. The dispose-race one does not fail as a red test if
it regresses -- it takes the run with it, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExF9yGxB83NgQo6zJwcQMM
@sebgod
sebgod merged commit 93c04f6 into main Aug 20, 2026
2 checks passed
@sebgod
sebgod deleted the sebastiangodelet/accept-loop-survives-shutdown branch August 20, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant