Abik Maharjan · Backend Developer

WritingKathmandu--:--:--

← Writing

Ctrl-C Is a Feature

Every tool demo shows the happy path. The reason you trust a tool is what it does the moment you interrupt it, and almost nobody designs that part.

Here is a test you can run on any CLI tool you depend on.

Start something that takes a while. Halfway through, press Ctrl-C. Then answer three questions: What finished? What was in flight? What never started?

Most tools cannot tell you. You get a stack trace, or a bare ^C and a shell prompt, and now you are in the worst possible state, which is not failure but ambiguity. You do not know whether to run it again. Running it again might redo work, or skip work, or corrupt something halfway through. So you go and check by hand, which is the exact thing the tool was supposed to save you from.

I have written a handful of small tools over the last few months. A parallel git pull runner, an SSH daemon for AI agents. They do completely unrelated things. But when I look at where the design effort actually went in each one, it is the same place every time, and it is never the feature.

It is the interrupt.


Failed, canceled, and never started are three different states

Here is the summary line from gopull after a clean run:

1 updated, 1 up to date, 1 failed

And here it is after I hit Ctrl-C partway through eight repositories:

interrupt: stopping (Ctrl-C again to force quit)
· slow1 (canceled)
0 updated, 2 up to date, 0 skipped, 0 failed, 1 canceled, 5 not pulled
interrupted

Six categories. That looks like over-engineering until you consider what happens if you collapse them.

If canceled gets reported as failed, then interrupting a run produces a screen of red, and I have to figure out which of those failures were real. If not pulled gets reported as skipped, I cannot tell the difference between "this repo was deliberately left alone because it is on a detached HEAD" and "the run ended before this repo's turn came up." One of those is a decision the tool made and the other is just where the clock stopped.

The exit codes follow the same rule. Zero when everything pulled cleanly, 1 if anything genuinely failed, and 130 when the run was interrupted, because 130 is the conventional 128 + SIGINT and any script wrapping this tool deserves to know the difference between "your repos are broken" and "a human stopped me."

None of this is hard to implement. It is entirely a matter of deciding, before you write the loop, that “did not happen” is not one state.


signal.NotifyContext is a trap for CLI tools

This is the part I would most like every Go developer to read, because the standard library quietly steers you into a bad place.

Go 1.16 added signal.NotifyContext, and it is genuinely lovely:

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()

One line, context cancels on Ctrl-C, done. I use exactly this in my SSH daemon and it is the right call there. But for an interactive CLI it has a failure mode that took me an embarrassingly long time to notice: it stops listening after the first signal.

Read that again with a user in mind. They press Ctrl-C. Your tool begins winding down gracefully. Something is stuck. They press Ctrl-C again, harder. Nothing happens. They press it eight more times. Nothing happens, because NotifyContext deregistered the handler after the first one and every subsequent signal is being silently swallowed by your own program.

You have built a tool that cannot be escaped. The user’s only remaining option is another terminal and kill -9, and now you are back to leaving lock files everywhere, which is the thing graceful shutdown was for.

So gopull does it by hand:

// interruptible returns a context canceled by the first interrupt signal.
// A second signal quits on the spot, so a run that refuses to wind down can
// still be escaped. Note signal.NotifyContext cannot do this: it stops
// listening after the first signal and silently drops the rest.
func interruptible() context.Context {
        ctx, cancel := context.WithCancel(context.Background())
        sig := make(chan os.Signal, 2)
        signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
        go func() {
                <-sig
                fmt.Fprintln(os.Stderr, "\ninterrupt: stopping (Ctrl-C again to force quit)")
                cancel()
                <-sig
                os.Exit(130)
        }()
        return ctx
}

Twelve lines. First signal cancels the context and tells the user what just happened and what their next option is. Second signal exits immediately. The channel is buffered at 2 because signal.Notify drops signals rather than blocking, and a user hammering Ctrl-C can absolutely deliver two before that goroutine is scheduled.

The general principle: graceful shutdown must always have an escape hatch, and you have to tell the user it exists at the moment they need it. A progress bar that keeps moving after Ctrl-C, with no explanation, is indistinguishable from a hang.


Ask the process to stop. Do not kill it.

gopull shells out to git. When the context cancels, Go's default behavior for exec.CommandContext is to send SIGKILL to the child.

SIGKILL is not catchable. A git pull killed with SIGKILL leaves .git/index.lock on disk. The next time you touch that repository, from any tool, you get:

fatal: Unable to create '.../.git/index.lock': File exists.

Now my convenience tool has broken a repository in a way that requires the user to know about lock files to fix. That is a bad trade for saving two seconds on shutdown.

// SIGTERM rather than the default SIGKILL: git removes its lock files
// on the way out. WaitDelay kills it if it does not take the hint.
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
cmd.WaitDelay = 2 * time.Second

cmd.Cancel and cmd.WaitDelay landed in Go 1.20 and are two of the most useful additions to os/exec in years, and I almost never see them used. Together they express the only correct shutdown policy for a child process: ask nicely, then insist. Git gets SIGTERM, cleans up after itself, exits. If it is wedged and ignores that, it gets two seconds and then dies for real.

If you spawn subprocesses in Go and you have never set these two fields, you are killing your children with SIGKILL and inheriting whatever mess that leaves.


The race nobody thinks about

Here is a bug that only exists because of how terminals work, and it is the kind of detail that separates a tool that feels solid from one that feels flaky.

When you press Ctrl-C in a terminal, the signal goes to the entire foreground process group. That is not just my program. That includes the git process my program spawned.

So there is a race. Sometimes my signal handler wins, cancels the context, and cmd.Cancel sends git a SIGTERM. But sometimes the terminal's SIGINT reaches git first, and git dies on its own a few milliseconds before my program gets around to canceling anything. From my code's perspective the context is not canceled yet, git just exited nonzero, and that looks exactly like a failed pull.

The result is a tool that, maybe one interrupt in five, reports a phantom failure for a repository that was perfectly fine. That kind of bug is miserable to chase because it is timing-dependent and you cannot reproduce it on demand.

// killedBySignal reports whether git died from a signal rather than exiting
// on its own. A terminal Ctrl-C reaches git directly, and it can die that way
// a moment before this process gets around to canceling the context - without
// this check such a pull would be reported as a failure.
func killedBySignal(err error) bool {
        var exitErr *exec.ExitError
        if !errors.As(err, &exitErr) {
                return false
        }
        return exitErr.ExitCode() == -1
}

Go reports an exit code of -1 when a process was terminated by a signal rather than exiting normally. Checking for it closes the race: a pull is Canceled if the context was canceled or git died by signal. I also blank the captured output in that branch, because git's dying words are noise about being interrupted and printing them makes an orderly shutdown look like a catastrophe.


Resume is a schema problem, not a loop problem

Different tool, same obsession.

outreach is a desktop mail-merge app. You write one template, import a list, and it sends personalized emails through your own Gmail, slowly, one at a time. A campaign can run for hours. Laptops close. Processes crash. Gmail cuts you off at a daily limit.

So the guarantee that matters is not “it sends emails.” It is: nobody is ever emailed twice.

The naive implementation of that is a set of already-sent addresses that the send loop checks before each message. This works right up until the loop has a bug, or a retry path forgets to consult it, or you add a “retry failed recipients” feature six weeks later and it re-emails everyone who bounced.

I did not want that guarantee to live in a loop. I wanted it to live in the database:

-- The UNIQUE constraint is the backbone of resume-safety: it makes sending the
-- same address twice within one campaign structurally impossible, not merely
-- something the send loop tries to remember to check.
CREATE TABLE IF NOT EXISTS send_log (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
    email       TEXT NOT NULL,
    status      TEXT NOT NULL,
    sent_at     TEXT NOT NULL,
    UNIQUE (campaign_id, email)
);

UNIQUE (campaign_id, email). That one line does more for correctness than any amount of care in the send loop, because it converts a rule I have to remember into a rule the database enforces. The insert raises IntegrityError, which the storage layer turns into a typed AlreadySentError, and any code path that tries to double-send gets an exception instead of quietly sending an email to a real human being.

There is still a pre-flight check that filters already-sent addresses before the run starts, because raising exceptions is a bad way to control flow. But the check is the optimization and the constraint is the guarantee. I keep both, and the comment in the code says as much, because in six months I will have forgotten which one I was relying on.

Write down the invariant in the strongest place the stack allows. A comment is the weakest. A runtime check is stronger. A type is stronger still. A database constraint is close to the strongest thing available to a small app, because it survives your future self.


Commit immediately, not in batches

The corollary, from the same file:

Committed immediately rather than batched: a crash or force-quit then costs at most one message of state.

Batching writes is the reflexive optimization here. One transaction per hundred emails, obviously faster. Except this app deliberately waits 30 to 90 seconds between messages, so the write throughput is roughly one row per minute. There is no performance problem to solve. Batching would buy nothing and cost me the crash-resume guarantee, because a crash mid-batch means the log disagrees with reality about what was actually sent.

Optimizing a thing that is not slow, at the cost of a thing that is load-bearing, is one of the most common ways good engineers make software worse. The right question is not “is this fast” but “what does this cost me when it stops halfway.”


Do not build a limit that quitting the app defeats

Gmail will disable a free account that sends more than around 500 messages a day. The app caps at 400 to leave headroom.

The question is where that counter lives. If it is a variable on the sender object, then closing the app resets it to zero, and the daily cap protects you right up until the moment you are impatient. A limit that is trivially defeated by the most obvious user action is not a limit, it is a suggestion with a progress bar.

So the cap is not a counter. It is a query:

daily_cap - (rows in send_log where sent_at > now - 24h)

Derived from the same durable log that powers resume, over a rolling window. Quit and relaunch and the number is unchanged, because it was never in memory to begin with. It is one of my favorite things in the codebase, and it is not a feature. It is the absence of one.

There is a general shape here. When a rule exists to protect the user from a consequence they cannot see (a suspended Gmail account, three days later), the rule has to be harder to circumvent than the impatient action it is guarding against. Otherwise it only binds the users who did not need it.


Cancel has to interrupt the wait

Last one, and it is small, but it is the difference between a cancel button people trust and one they do not.

The sender waits 30 to 90 seconds between messages. If you implement that as time.sleep(seconds), then pressing Cancel does nothing visible for up to a minute and a half. The user presses it again. Then they force-quit, which is exactly the ungraceful exit all this machinery exists to avoid.

def _delay(self) -> None:
    """Wait a random interval, returning early if cancelled.
    Randomised rather than fixed: an unvarying cadence is itself a signal
    that the sender is automated.
    """
    seconds = random.uniform(low, high)
    self._wake.wait(timeout=seconds)
    self._wake.clear()

threading.Event.wait(timeout=...) instead of sleep. Cancel sets the event, the wait returns instantly, the loop exits. The rule generalizes past Python: never block on a duration when you could block on an event with a timeout. Any sleep in a cancellable path is a delay you are personally imposing on your user's decision to stop.

(The docstring notes the other reason for the randomization: a fixed cadence is itself a fingerprint. Sending exactly one email every 60.0 seconds is a machine signature no human types. But that is a deliverability post, not this one.)


When to skip all of this

I want to be clear that this is judgment, not a checklist, because the same discipline applied indiscriminately is just ceremony.

My SSH daemon uses signal.NotifyContext, the exact thing I argued against above, and that is correct. A daemon under launchd or systemd is not interactive. Nobody is hammering Ctrl-C at it. It receives exactly one SIGTERM from a service manager that will SIGKILL it after a grace period anyway, so the double-signal escape hatch would be dead code. It drains for five seconds, removes its socket so the next start does not hit EADDRINUSE, and exits.

Different context, different answer. The question is never “did I handle signals properly,” it is “who is going to interrupt this, why, and what do they need to be true afterward?” For a CLI it is an impatient human who needs an escape hatch. For a daemon it is a service manager that needs a clean socket. For the mail sender it is someone closing a laptop who needs to not email a stranger twice.


Why I actually care about this

I spend my working hours on distributed backends, mostly Cloudflare Workers, Durable Objects, and Queues. In that world none of the above is a novel insight, it is the baseline. Queues deliver at least once, not exactly once, which means your consumer will occasionally see the same message twice and it is your job to make that harmless. You learn to reach for idempotency keys immediately, because the alternative is charging someone’s card twice and finding out from them.

Then you close the laptop, open a personal project, and write a 400-line CLI with a bare time.sleep and no exit-code discipline, because it is just a small tool.

The thing that changed how I build is realizing those are the same problem at different scales. UNIQUE (campaign_id, email) is an idempotency key. Reporting canceled separately from failed is a delivery receipt. cmd.WaitDelay is a graceful-shutdown grace period. My laptop closing mid-campaign is a consumer crashing mid-batch, and the mitigation is identical: durable state written immediately, and a constraint that makes the bad outcome structurally impossible rather than merely unlikely.

Small tools are not exempt from distributed systems problems. They just have a single node, and the network partition is you, closing the lid.

The happy path is the part of a tool that gets demoed. The interrupt path is the part that determines whether anyone keeps using it after the first time something goes wrong. Only one of those is worth designing carefully, and it is not the one in the README screenshot.


gopull_ is on GitHub and installs with go install github.com/NickName-AM/gopull@latest. Press Ctrl-C twice._

0 views