skip to content

How do the Cmd.Cancel and Cmd.WaitDelay fields change what exec.CommandContext does on cancellation?

level: middleimportance: should knowfreq 38%

answer

  1. the default kill is only a default
  2. one field chooses the action, one bounds it
  3. a grace period before the hard stop
  4. zero delay means wait forever

basics

~20 s

Cmd.Cancel replaces the default hard kill with your own action, typically sending SIGTERM so the child can shut down. Cmd.WaitDelay bounds how long that grace period lasts: when it expires, os/exec kills the process and stops waiting.

solid answer

~40 s

For a `*exec.Cmd` created by `exec.CommandContext`, `Cmd.Cancel` is the function called when the context is done, and it defaults to `cmd.Process.Kill()`. Setting it yourself — usually `cmd.Process.Signal(syscall.SIGTERM)` — turns the abrupt kill into a request the child can act on. That alone is unsafe, because a child that ignores the signal would hang `Wait` forever, which is what `Cmd.WaitDelay` is for: it bounds the wait, and when the delay elapses `os/exec` terminates the process with `os.Process.Kill` and closes the pipes it created. Both fields must be set before `Start`. There is a subtlety in the return value: if the command exits successfully after `Cancel` was called, `Wait` still reports an error — either wrapping the one `Cancel` returned or the context's error — unless `Cancel` returned something matching `os.ErrProcessDone`.

code

go · 10 lines
go
cmd := exec.CommandContext(ctx, "/usr/local/bin/dumpdb", "--out", path)
cmd.Cancel = func() error {
	return cmd.Process.Signal(syscall.SIGTERM)
}
cmd.WaitDelay = 10 * time.Second

if err := cmd.Run(); err != nil {
	// after ctx is done: SIGTERM, then Kill 10s later if still alive
	return err
}

go deeper

for a junior

Know that these two fields exist and that they are what turns a hard kill into a polite signal with a deadline behind it. Naming Cmd.Cancel and Cmd.WaitDelay and saying what each is for is enough here.

for a middle

Explain the mechanics precisely: Cancel is invoked when the context is done, WaitDelay's timer starts at cancellation or observed exit, and expiry means os.Process.Kill plus closed pipes.

for a senior

Demonstrate the sizing judgment — a delay shorter than the child's real shutdown time produces exactly the half-finished state graceful termination was meant to avoid, and the delay sits inside the caller's latency budget.

for a principal

Own it as a platform default: one grace period applied everywhere is a promise about how much time any workload gets to stop, and exceptions to it should be argued per workload rather than copied per service.

## Two knobs on one behaviour Cancellation in `os/exec` is not hard-wired. A `*exec.Cmd` exposes two fields that together define the whole policy: `Cancel func() error` and `WaitDelay time.Duration`. Both must be set before `cmd.Start`; changing them afterwards has no effect on a command already running. ### Cmd.Cancel — what cancellation actually does `Cancel` is the function `os/exec` calls when the command's context becomes done. It is only meaningful on a command created with `exec.CommandContext`; setting it on a plain `exec.Command` is an error because there is no context to trigger it. When `CommandContext` builds the command, it pre-sets `Cancel` to a function that calls `Kill` on the process. Replacing it is how you get anything softer: ```go cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } ``` A `Cancel` function is not restricted to signals. The documented alternatives include closing the child's standard input so it sees EOF and exits on its own, or sending a shutdown request over a socket the child is listening on. What matters is that it is *your* code deciding what "stop" means for this particular program. The error `Cancel` returns is not discarded. If the command then exits *successfully*, `Wait` still returns a non-nil error — either one wrapping what `Cancel` returned, or the context's error — because a success that arrived after you asked the program to stop is ambiguous and the package refuses to paper over it. The exception is `os.ErrProcessDone`: if `Cancel` returns an error matching that (the process had already finished by the time the context fired), the ordinary exit status stands. If the command exits with a *failure* status, you get the usual exit error regardless. ### Cmd.WaitDelay — the grace period A polite signal is only safe if you are willing to escalate. `WaitDelay` is the escalation. It bounds two sources of unexpected delay in `Wait`: a child that fails to exit after the context was cancelled, and a child that has exited but left the I/O pipes open. The timer starts at whichever comes first — the context becoming done, or `Wait` observing that the child exited. When it elapses, `os/exec` shuts the situation down: if the process is still alive it is terminated with `os.Process.Kill`, and any pipes still open are closed so that goroutines blocked on them are released. The default value is zero, which means *no bound at all*: `Wait` waits as long as it takes. So the standard safe pattern is a pair: ```go cmd := exec.CommandContext(ctx, prog, args...) cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = 10 * time.Second ``` SIGTERM on cancellation; SIGKILL ten seconds later if the child is stubborn. That is the shape you want for anything that owns state — a build, a dump, a job that writes files — and it is exactly what the bare default cannot express. ### The error you get from the delay When the delay expires because pipes were still open, no `Cancel` call happened, and the command otherwise exited successfully, `Wait` returns `exec.ErrWaitDelay` instead of `nil`. That is a deliberately narrow signal: it tells you the command itself succeeded but the package gave up waiting on the plumbing rather than blocking indefinitely. Treating it as a fatal command failure is usually wrong; treating it as a warning worth logging is usually right. ### Choosing the numbers There is no universally correct `WaitDelay`. It is a statement about how long you are willing to let a cancelled child hold your resources — a worktree, a lock, a slot in a concurrency limit. A few seconds is generous for a program whose shutdown is closing files; it is far too short for one that flushes a large buffer. If the delay is shorter than the child's honest shutdown time, you get the worst of both worlds: a signal that starts an orderly shutdown and a kill that interrupts it halfway, leaving exactly the partial state you were trying to avoid. The other half of the sizing decision is what the caller is waiting for. If a request handler is blocked on this command, your `WaitDelay` is part of that request's latency budget, and it must fit inside the deadline the caller already has. ### Ordering and lifetime Both fields belong to a specific `*exec.Cmd`, and a `Cmd` cannot be reused: once it has been run, you build a new one for the next execution. The closure you assign to `Cancel` typically captures `cmd` itself so it can reach `cmd.Process`, which only exists after `Start` — that is fine, because `Cancel` is never called before the process is started. ### What these two fields still do not solve A custom `Cancel` and a `WaitDelay` control what happens to *the process you started*. Neither reaches the processes that process started. If the child is a shell running a pipeline, SIGTERM to the shell and SIGKILL after the delay both address the shell only. Terminating a whole tree is a separate concern, and it is the reason process groups exist.

  • What is Cmd.WaitDelay's default value, and why is that dangerous with a custom Cancel?
    It defaults to zero, which means no bound: `Wait` blocks as long as the child takes. With the built-in kill that is fine, since the child is already dying. With a custom `Cancel` that only sends SIGTERM, a program that ignores the signal keeps the `Wait` — and whatever goroutine is in it — blocked indefinitely. A non-zero delay is what makes a polite signal safe.
  • If a custom Cancel sends SIGTERM and the child exits successfully afterwards, what does cmd.Wait return?
    A non-nil error: either one wrapping the error `Cancel` returned, or the context's error. The package refuses to report plain success for a command you asked to stop. The exception is a `Cancel` that returns an error matching `os.ErrProcessDone`, meaning the process had already finished before cancellation reached it.
  • Can Cmd.Cancel do something other than send a signal?
    Yes. It is an arbitrary function, so it can close the child's standard input to give it EOF, or send a shutdown request over a socket the child listens on. That is useful for children with no signal handling — a REPL-style tool, for instance, stops at EOF but ignores SIGTERM. `WaitDelay` still provides the escalation if the trick does not work.

saying these in an interview costs you the question

  • Thinks Cmd.Cancel can be set on a plain exec.Command with no context
  • Sets a custom Cancel that sends SIGTERM but leaves WaitDelay at zero
  • Believes WaitDelay is the total time limit for the command
  • Assumes a successful exit after Cancel gives a nil error from Wait
  • Expects WaitDelay's fallback kill to reach the child's own subprocesses