skip to content

Subprocess Control

Starting a child with exec.Command, wiring its stdin and stdout without deadlocking, and killing it when the context is done. Each of those three is a different production failure.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

13

What does exec.CommandContext do to the running child process when its context is cancelled?

level: juniorimportance: must knowfreq 52%

answer

  1. the context does not ask politely
  2. one signal, no negotiation
  3. descendants are not included
  4. you still have to collect the corpse

basics

~10 s

By 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 lines
go
ctx, 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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
open as a page

Why does exec.Command("ls", "*.go") in Go pass the glob to ls literally instead of expanding it?

level: juniorimportance: must knowfreq 52%

basics

~20 s

exec.Command starts the program directly through the operating system, with no shell involved. Every string after the program name becomes one exact argument, so ls receives the four characters *.go as text. Globbing, pipes, redirection and variable expansion are shell features.

open as a page

In Go's os/exec, how do cmd.Run, cmd.Output and cmd.CombinedOutput differ in what they capture?

level: middleimportance: must knowfreq 58%

basics

~20 s

Run just waits for the child and sends its output wherever Cmd.Stdout and Cmd.Stderr point, discarding it if they are nil. Output returns stdout as bytes. CombinedOutput returns stdout and stderr interleaved in one byte slice.

open as a page

In os/exec, what goes wrong if you call cmd.Wait() before finishing reads from cmd.StdoutPipe()?

level: middleimportance: must knowfreq 52%

basics

~20 s

cmd.Wait closes the pipes it created once the child exits, so unfinished reads lose whatever was still in flight and then fail against a closed file. Read the pipe to end of file first, then call cmd.Wait.

open as a page

In os/exec, when should you use cmd.StdoutPipe() instead of assigning a bytes.Buffer to cmd.Stdout?

level: juniorimportance: should knowfreq 40%

basics

~10 s

Use cmd.StdoutPipe when you must stream output as it arrives, or when the output is too large to hold in memory. Assigning a bytes.Buffer to cmd.Stdout is simpler: os/exec drains the child for you.

open as a page

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

level: middleimportance: should knowfreq 38%

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.

open as a page

Why must a Go program still call cmd.Wait after cmd.Process.Kill has terminated the child?

level: middleimportance: should knowfreq 44%

basics

~20 s

Kill only requests termination; it returns before the child is gone. cmd.Wait is what collects the exit status, removes the zombie entry from the process table on Unix, closes the pipes os/exec created, and fills in cmd.ProcessState.

open as a page

What does setting exec.Cmd.Env do to a Go child process, and which PATH resolves the binary?

level: middleimportance: should knowfreq 41%

basics

~20 s

A nil Cmd.Env means the child inherits the parent's whole environment. Setting it replaces the environment entirely, not adds to it. The binary is still resolved by exec.LookPath using the calling process's PATH, which Cmd.Env cannot change.

open as a page

Why does a child started with os/exec hang forever when you write to cmd.StdinPipe() but never close it?

level: middleimportance: should knowfreq 46%

basics

~20 s

Filters that consume all their input read until end of file, and a pipe reports end of file only when every write end is closed. Your program still holds one, so the child waits and cmd.Wait waits with it.

open as a page

In Go's os/exec, how do you tell a child's nonzero exit from a failure to launch it at all?

level: seniorimportance: should knowfreq 46%

basics

~20 s

Match the error with errors.As. An *exec.ExitError means the program ran and exited nonzero, and its ExitCode method gives the status. Any other error, such as an *exec.Error wrapping exec.ErrNotFound, means it never ran. A nil error means it exited zero.

open as a page

Why does writing 200 MB to cmd.StdinPipe() before reading cmd.StdoutPipe() deadlock when piping a dataset through an external compressor?

level: seniorimportance: should knowfreq 35%

basics

~10 s

The child's output pipe fills after roughly 64 KB, so the compressor blocks writing output and stops reading input; the parent then blocks writing input. Feed stdin on one goroutine while another drains stdout.

open as a page

Your CI runner kills a build step with exec.CommandContext, yet processes it spawned keep running. Why?

level: seniorimportance: nice to knowfreq 30%

basics

~10 s

Because os.Process.Kill signals exactly one process: the shell you launched. Its own children are never told, are re-parented to PID 1, and keep running. Killing a tree needs a process group.

open as a page

As the owner of a Go orchestrator, how do you decide which external CLIs it may shell out to?

level: principalimportance: nice to knowfreq 24%

basics

~20 s

Treat every os/exec call site as an undeclared dependency on a binary, its flags, its output format and its exit codes, on hosts you may not own. Keep the ones where no usable library exists and the output is machine-readable, resolve them at startup, and agree the versions with whoever maintains the hosts.

open as a page