Problem
The server installs no SIGPIPE handler. Any write to a broken pipe (write end open, read end gone) is killed by the kernel's default SIGPIPE disposition before write() returns to Swift — so no try? / do-catch can intercept it. There are 27 FileHandle.standardError.write sites across Sources/ (Server.swift, MailController.swift, ExportEmailsMarkdown.swift) and grep finds no signal() / SIG_IGN. Every one of them is a potential SIGPIPE kill if the host closes the read end of the server's stderr (or stdout) while the server keeps running.
Verified on the shipped binary (not a proxy)
Launched the real CheAppleMailMCP with fd 2 = the write end of a reader-closed pipe:
| Scenario |
Result |
version drift present (#303 emitDiagnostic fires at startup) |
server killed by signal 13 (SIGPIPE) |
no drift / no sidecar (emitDiagnostic silent) |
server survives |
| standalone probe, default disposition |
exit 141 (128+SIGPIPE) |
standalone probe, signal(SIGPIPE, SIG_IGN) first |
exit 0, survives |
So #303's staleness warning is not a new crash class — it is the earliest stderr write on the startup path, which makes an already-fatal fd-2 state fatal at startup (on the drift path) rather than at the first log write during tool use. The other 26 writers would kill the same fd-2 state later regardless.
Surfaced by the round-5 verify of PR #307; the CHANGELOG + emitDiagnostic doc comment there now state this boundary precisely rather than claiming blanket "crash-proof".
Reachability
Narrow for a normally-launched stdio MCP server: the parent (Claude Desktop / launcher) normally holds the read end of the server's stderr for logging, so fd 2 does not become a broken pipe on its own. It requires the host to close its stderr reader while keeping the server alive. But it is a real state, and the failure is a hard SIGKILL, not a graceful degrade.
The fix is not a one-liner — hence a separate issue
The obvious fix is a process-wide signal(SIGPIPE, SIG_IGN) at startup, which converts the kill into an EPIPE errno the throwing write already swallows — fixing all 27 sites at once. But SIG_IGN without corresponding EPIPE handling on the stdout path can turn a clean "client disconnected → exit" into a spin: today a stdout write after the client closes gets SIGPIPE and the process dies (crude but terminal); with SIG_IGN it returns EPIPE, and the server must then detect that (or stdin-EOF) and exit cleanly. So this needs the MCP stdio server's shutdown path validated before flipping the disposition — deliberately out of #303's scope.
Suggested approach
- Confirm the server exits cleanly on stdin EOF (the normal client-disconnect signal), independent of stdout
SIGPIPE.
- Add
signal(SIGPIPE, SIG_IGN) once at process startup.
- Verify a broken-pipe stdout no longer spins (relies on step 1).
- A regression test spawning the real binary with a broken-pipe fd 2 (the harness above) asserting it survives.
Related
Current Status
- Phase: diagnosed
- Updated: 2026-08-01 (idd-all batch)
- Diagnosis: see the
## Diagnosis comment below (root cause, strategy checklist, conflict class, complexity)
Problem
The server installs no
SIGPIPEhandler. Any write to a broken pipe (write end open, read end gone) is killed by the kernel's defaultSIGPIPEdisposition beforewrite()returns to Swift — so notry?/do-catchcan intercept it. There are 27FileHandle.standardError.writesites acrossSources/(Server.swift, MailController.swift, ExportEmailsMarkdown.swift) andgrepfinds nosignal()/SIG_IGN. Every one of them is a potentialSIGPIPEkill if the host closes the read end of the server's stderr (or stdout) while the server keeps running.Verified on the shipped binary (not a proxy)
Launched the real
CheAppleMailMCPwith fd 2 = the write end of a reader-closed pipe:emitDiagnosticfires at startup)emitDiagnosticsilent)signal(SIGPIPE, SIG_IGN)firstSo #303's staleness warning is not a new crash class — it is the earliest stderr write on the startup path, which makes an already-fatal fd-2 state fatal at startup (on the drift path) rather than at the first log write during tool use. The other 26 writers would kill the same fd-2 state later regardless.
Surfaced by the round-5 verify of PR #307; the CHANGELOG +
emitDiagnosticdoc comment there now state this boundary precisely rather than claiming blanket "crash-proof".Reachability
Narrow for a normally-launched stdio MCP server: the parent (Claude Desktop / launcher) normally holds the read end of the server's stderr for logging, so fd 2 does not become a broken pipe on its own. It requires the host to close its stderr reader while keeping the server alive. But it is a real state, and the failure is a hard
SIGKILL, not a graceful degrade.The fix is not a one-liner — hence a separate issue
The obvious fix is a process-wide
signal(SIGPIPE, SIG_IGN)at startup, which converts the kill into anEPIPEerrno the throwing write already swallows — fixing all 27 sites at once. ButSIG_IGNwithout correspondingEPIPEhandling on the stdout path can turn a clean "client disconnected → exit" into a spin: today astdoutwrite after the client closes getsSIGPIPEand the process dies (crude but terminal); withSIG_IGNit returnsEPIPE, and the server must then detect that (or stdin-EOF) and exit cleanly. So this needs the MCP stdio server's shutdown path validated before flipping the disposition — deliberately out of #303's scope.Suggested approach
SIGPIPE.signal(SIGPIPE, SIG_IGN)once at process startup.Related
write(contentsOf:)), which works there only because that write is not to the process's own fd 1/2 default-disposition path.emitDiagnosticas the earliest trigger and documented the boundary.Current Status
## Diagnosiscomment below (root cause, strategy checklist, conflict class, complexity)