What does exec.CommandContext do to the running child process when its context is cancelled?
answer
- the context does not ask politely
- one signal, no negotiation
- descendants are not included
- you still have to collect the corpse
basics
~10 sBy default it kills the child outright with os.Process.Kill, which is SIGKILL on Unix, as soon as the context is done. No graceful signal, no grace period, and the program must still call cmd.Wait.
solid answer
~40 s`exec.CommandContext` builds an ordinary `*exec.Cmd` but remembers the `context.Context`. When that context is done — cancelled by hand or by a deadline from `context.WithTimeout` — the package calls the command's cancel function, which by default is `cmd.Process.Kill()`. On Unix that is SIGKILL: the child gets no chance to flush, clean up temp files, or shut down its own work. Only the direct child is signalled; anything it spawned keeps running. The kill is asynchronous, so `cmd.Wait` (or `cmd.Run`, which is `Start` plus `Wait`) is what actually collects the exit status and releases the process. Because the child was signalled, `Wait` returns an `*exec.ExitError` rather than `nil`, so the usual way to tell "my command failed" from "I cancelled it" is to check `ctx.Err()` after `Wait` returns.
code
go · 11 linesctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sleep", "30")
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
// the deadline killed it: err is *exec.ExitError, signal kill
return ctx.Err()
}
return err // the program failed on its own
}go deeper
Be ready to say plainly that the default is a hard kill with os.Process.Kill, that it is SIGKILL on Unix, and that you still call cmd.Wait or cmd.Run afterwards to reap the child.
Explain the mechanics: the context's done channel triggers the command's cancel function, the kill is asynchronous, and the resulting error is an *exec.ExitError while ctx.Err() is what tells you it was a timeout.
Show the judgment about when a hard kill is acceptable at all — untrusted or unbounded commands, yes; a database dump mid-write, no — and know that the default touches only the direct child.
Frame it as policy: what your platform promises about commands it runs on shared hosts, whether every execution path carries a deadline, and who is accountable when a killed step leaves half-written artefacts behind.
## What CommandContext adds `exec.Command(name, args...)` returns a `*exec.Cmd` describing an external program to run. `exec.CommandContext(ctx, name, args...)` returns exactly the same thing, with one addition: the `Cmd` remembers the `context.Context` you passed, and its `Cancel` field is pre-set to a function that calls `Kill` on the command's `os.Process`. That is the whole mechanism — there is no hidden supervisor and no signal negotiation. ## When the kill fires The context becomes done for any of the usual reasons: you called the `cancel` function from `context.WithCancel`, the deadline from `context.WithTimeout` or `context.WithDeadline` expired, or a parent context further up the call chain was cancelled. At that moment the `os/exec` package invokes the cancel function. With the default, that means `os.Process.Kill()`, which on Unix sends SIGKILL and on Windows terminates the process. SIGKILL cannot be caught, blocked, or ignored, so the child dies immediately without running any shutdown code of its own. Two edge cases are worth knowing. If the context is already done before you call `cmd.Start`, the program is never launched at all. And once the child has exited on its own and been waited for, a later cancellation has nothing to signal — the package will not resurrect or re-signal a finished process. ## You still have to wait Sending a kill signal is not the same as collecting the child. On Unix an exited process stays in the kernel's process table as a zombie until its parent reaps it, and in Go that reaping is `cmd.Wait` (or `cmd.Run`, which calls `Start` and then `Wait`). `Wait` also closes the pipes the package created on your behalf and fills in `cmd.ProcessState`. A program that starts a command, lets the context kill it, and then simply moves on leaks a zombie entry plus the bookkeeping `os/exec` set up for the child. So the shape that actually works is: ```go ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "sleep", "30") err := cmd.Run() // Run = Start + Wait, so the child is reaped ``` The `defer cancel()` matters even when the command finishes early: not calling it leaks the timer and the context's resources until the deadline passes. ## What Wait returns after a cancellation Because the child was terminated by a signal rather than exiting successfully, `Wait` returns a non-nil error — an `*exec.ExitError` wrapping the `os.ProcessState`. That state reports termination by signal, and `ProcessState.ExitCode()` returns -1 for a signalled process rather than a real exit code. The error itself does not say "cancelled"; it says "this program died". The idiomatic way to distinguish the two cases is to look at the context afterwards: ```go if err := cmd.Run(); err != nil { if ctx.Err() != nil { // we killed it: timeout or cancellation } else { // the program itself failed } } ``` ## What it deliberately does not do Three limits catch people out. First, **there is no graceful stop**. A build tool, a database dump, or a long-running compiler gets SIGKILL and leaves partial output behind. If you need the child to shut down cleanly, you replace the default cancel behaviour with your own function that sends SIGTERM, and bound how long you will tolerate a child that ignores it. Second, **only the direct child is signalled**. If you launched a shell that launched a compiler, killing the shell leaves the compiler running; it is simply re-parented and carries on. Terminating a whole tree requires putting the child in its own process group and signalling the group. Third, **the kill is not synchronous**. `Kill` requests termination; it returns before the operating system has finished tearing the process down. Code that kills a child and immediately deletes its working directory is racing with a process that may still hold files open. Waiting first is what makes that safe. ## The mental model Treat `exec.CommandContext` as a hard deadline enforcer, not as a shutdown protocol. It guarantees that a runaway child cannot outlive your context, which is exactly what you want for an untrusted or unbounded command. Everything softer than that — a warning signal first, a grace period, cleaning up descendants — is something you configure explicitly on top of it.
- After the context kills the command, does cmd.Run return the context's error?No. With the default kill behaviour the child dies by signal, so `Run` returns an `*exec.ExitError` describing that death, not `context.DeadlineExceeded` or `context.Canceled`. Check `ctx.Err()` separately if you need to report a timeout distinctly from a genuine command failure; many programs wrap the two into one message deliberately.
- What happens if the context is already cancelled before you call cmd.Start?The external program is never launched. `Start` sees the done context and fails immediately, so there is no process, nothing to signal, and nothing to reap. This is why passing an already-expired context is a silent way to get "the command never ran" rather than a partial execution.
- Why is defer cancel() still needed if the command finishes long before the deadline?The `cancel` function from `context.WithTimeout` releases the timer and the context's internal bookkeeping. Skipping it keeps those alive until the deadline elapses, which in a loop that runs thousands of commands is a real accumulation. Calling `cancel` after the work is done is always safe and always correct.
It is a circuit breaker, not a shutdown request: it cuts power to the one machine you named, and nothing downstream of it notices anything but sudden silence.
saying these in an interview costs you the question
- Thinks the child is sent SIGTERM and gets to clean up
- Believes cancellation kills the child's own subprocesses too
- Assumes cmd.Wait is unnecessary once the context killed the process
- Expects cmd.Run to return context.DeadlineExceeded after a timeout
- Thinks os.Process.Kill blocks until the child has fully exited