A commit had gone into go-tool-base's root command about a month before I noticed it, and it had been quietly breaking things for anything downstream that used the controls module the whole time. I didn't find it. Someone raised it with me, which is not the order I'd have chosen, and it's on me for not spotting it when it went in.
Two right answers, one process
The commit gave the root command a signal-aware execution context: catch SIGINT, cancel the context, let the command wind itself up properly. That's a good change, and an obviously right one. A CLI wants to flush buffered telemetry before it dies, clean up a half-written self-update, and let a long-running command notice it's been asked to stop.
I'd have approved it. I did approve it, apparently (without reading it anything like carefully enough), and the trouble is what was already sitting underneath. go/controls is the supervisor that runs long-lived services and stops them in the right order, which I wrote up in lifecycle management for long-running Go services. And for most of its life it caught SIGINT and SIGTERM itself, by default.
That was also correct! A supervisor sat in a bare main really is the outermost thing in the process, and catching the interrupt to drive a graceful stop is its job. Doing it without being asked is a kindness. Two right decisions, eighteen months apart, and a collision that only exists where they overlap.
Nothing errors, which is the problem
Go will happily let two parts of one process both handle Ctrl-C, because signal.Notify is additive: register a second channel and it doesn't replace the first, it starts posting to both. No error, no warning, no log line saying "you appear to have two of these". Just two shutdown drivers, racing. So a default about process-global state is less a default about your library and more a claim about your position in someone else's process, and the post doesn't arrive telling you when that position has changed.
My own tools had already told me
So I went and looked at what the downstream tools were actually doing, and this is the part that smarted. Two of them had hit this unaided and written essentially the same workaround, with the same reasoning, without ever comparing notes: one had a comment explaining that the framework's root command already installs the handler that cancels the context so a second one would race it, and the other said it drives its stop from context cancellation specifically to avoid a second handler.
Same conclusion, same boilerplate, arrived at independently by two tools I wrote, working around a problem I'd shipped. And a third had never opted out at all. It had been running both handlers the whole time and I hadn't noticed, because from the outside this looks like a slightly chatty shutdown.
When two of your users pay the same tax and a third is silently broken, I think you can put the design principles away. The users had already told me the default was wrong, and they'd gone to the trouble of writing it in the comments (my comments, in my tools, which somehow makes it worse).
The part that actually hurt
Duplicated log lines are the visible symptom, and they're the least of it. controls publishes a contract. When a service's context is done, context.Cause(ctx) tells you why: if it's ErrShutdown, your controller stopped you deliberately, and anything else means something upstream died and you should behave differently. That's how a service decides whether to finish flushing or bail out now.
With two handlers, that becomes a coin flip. WithCancelCause hands you the parent's cause when the parent cancels first, so if the framework's handler wins the race, the controller's own cancel(ErrShutdown) turns up late and does nothing at all.
Same code, same signal, and a different answer depending on which goroutine the scheduler fancied. A published guarantee, broken by scheduling, with no error anywhere in sight. "It worked when I tested it" and "it is correct" have never been the same claim, and this is the gap that lives between them.
Somewhere else in the estate, I'd got it right
What convinced me this was a principle and not a signals quirk is that another module already handled it properly, by default, and had done from day one. go/transport-metrics sets up Prometheus collectors, and its documentation is blunt about it:
The default is a fresh, private registry (never the global
prometheus.DefaultRegisterer), so a tool's metrics are self-contained and tests do not collide.
Different process-global, identical reasoning, opposite instinct. Two libraries in one estate touching shared state, one grabbing it by default and one refusing to, and that comparison did more for the argument than anything I could have written in a spec.
The default went the other way round
NewController no longer installs a signal handler at all, and the doc comment explains itself so you don't have to work it out:
It does NOT install an OS signal handler. Signal disposition is process-global state and belongs to whichever layer is outermost, typically the CLI framework or main. Pass
WithSignalswhen the controller genuinely is that outermost layer.
The behaviour still exists, you just have to ask for it, and in a bare main where the supervisor really is the outermost thing it's WithSignals() and you're away. One small thing I got right by accident: the spec ended up raised against controls and not against the framework, because that's the module whose default was wrong. A boring filing decision, but it means the reasoning now lives next to the code it governs instead of in the repo of whoever happened to notice.
And keryx's comment has changed status without changing much text. It used to be a workaround describing what it was avoiding. It now reads as a note citing the decision. Same words, different thing.
The fix I didn't ship
The obvious worry with handing signals to the CLI is whether the service still gets long enough to shut down cleanly if the framework owns the interrupt. I had a whole cooperative-drain protocol specced out for it. Handshakes, acknowledgements, the lot.
Then I went and read three call sites, and found the graceful path already waits. Wait() blocks RunE, which blocks ExecuteContext, so the root command cannot physically reach its exit path until the controller has fully stopped. The problem I'd spent an evening designing around did not exist, and the only real gap was a single force-exit goroutine on the second Ctrl-C, which is a much smaller creature. An entire protocol, specced and binned, on the strength of going and reading the code instead of reasoning about it.
That's the second time in this story that the answer was sitting in a file I'd already written, once in my consumers' comments and once in my own call sites. I should probably read more and design less.
Originally published at phpboyscout.uk on 17 October 2026.
Top comments (0)