Why must a Go program still call cmd.Wait after cmd.Process.Kill has terminated the child?
answer
- sending a signal is not collecting a result
- the process table remembers exited children
- Kill returns before the child is gone
- one PID slot leaked per command
basics
~20 sKill 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.
solid answer
~50 s`cmd.Process.Kill()` sends the signal and returns — it does not block, and it does not clean anything up. On Unix an exited process stays in the kernel's process table as a zombie holding its exit status until its parent reaps it, and in Go that reaping is `cmd.Wait`. `Wait` does three jobs: it blocks until the child is genuinely gone, it collects the status into `cmd.ProcessState`, and it closes the descriptors and finishes the I/O copying that `os/exec` set up when you started the command. Skipping it leaks a process-table entry per command, which a long-running server notices. It also means the code after the kill runs while the child may still be alive, so deleting its working directory at that point races with a process that still has files open. `Wait` may be called only once per command, and only after `Start`.
code
go · 12 linesif err := cmd.Start(); err != nil {
return err
}
_ = cmd.Process.Kill() // returns before the child is actually gone
err := cmd.Wait() // blocks until it is; reaps the process-table entry
if cmd.ProcessState.ExitCode() == -1 {
// terminated by a signal rather than exiting with a status
}
os.RemoveAll(workdir) // safe only now
return errgo deeper
Remember that cmd.Run already waits for you, and that if you ever call cmd.Start yourself you owe the program a matching cmd.Wait. Say what a zombie process is in one sentence.
Explain the mechanics: the kernel keeps the exit status until the parent collects it, Kill returns before the child dies, and Wait is what closes descriptors and fills in cmd.ProcessState.
Show that you have seen the failure — climbing PID counts on a host running a service that shells out, healthy-looking metrics throughout, and forks failing machine-wide rather than only in your process.
Treat it as a shared-host risk: one service leaking PIDs takes down everything co-located, so the guardrail belongs in the internal library that runs commands rather than in each caller's review.
## Kill is a request, not an event `cmd.Process.Kill()` asks the operating system to terminate the process. On Unix that means delivering SIGKILL; on Windows it terminates the process. In both cases the call returns as soon as the request has been made, not when the process has finished dying. The child may still be executing for a short while afterwards — the kernel has to tear down its address space, close its descriptors, and release its locks. That asynchrony is the first reason `Wait` is not optional. Code like this is a race: ```go _ = cmd.Process.Kill() os.RemoveAll(workdir) // the child may still hold files here ``` On Linux the removal usually appears to succeed while the child keeps writing to descriptors it already holds; on Windows it can fail outright because files are still open. Waiting first is what makes "the child is gone" true. ## Zombies and reaping The second reason is the Unix process model. When a process exits, the kernel keeps a small record of it — its exit status and resource usage — so that its parent can find out what happened. Until the parent collects that record, the entry stays in the process table in the `Z` (zombie) state. It consumes no memory or CPU, but it occupies a PID slot, and the system has a finite number of them. The act of collecting that record is *reaping*. In Go it is `cmd.Wait` (or `cmd.Run`, which is `Start` followed by `Wait`, or `cmd.Output` and `cmd.CombinedOutput`, which also wait internally). A program that starts commands in a loop and never waits accumulates one zombie per command. On a long-running server — a job runner, a webhook handler that shells out, a scheduler — that reaches the PID limit and then nothing on the host can fork. Zombies vanish when the parent itself exits, because the orphaned records are re-parented to PID 1, which reaps them. That is why the bug so often hides in tests and short scripts and only appears in the daemon. ## What Wait does besides reaping `Wait` is also the point at which `os/exec`'s own bookkeeping finishes. When you assign an `io.Writer` such as a `bytes.Buffer` to `cmd.Stdout`, the package runs a goroutine copying the child's output into it; `Wait` is what waits for that copying to complete, which is why reading the buffer before `Wait` returns is unsafe. `Wait` also closes the descriptors the package opened on your behalf, and it populates `cmd.ProcessState`. That `ProcessState` is where the outcome lives. `ProcessState.Exited()` reports whether the process exited normally, and `ProcessState.ExitCode()` returns -1 rather than a real code when the process was terminated by a signal — which is exactly the case after a kill. If you want to report "killed" distinctly from "exited with status 2", that -1 plus your own knowledge that you sent the kill is how you do it. ## The rules around Wait A few constraints matter in review: - `Wait` must be called exactly once per command; a second call returns an error rather than a second status. - It must follow a successful `Start`. Calling it on a command that never started is a mistake. - After `Wait` has returned, the process is fully released. A later `cmd.Process.Signal` on it returns `os.ErrProcessDone` rather than reaching anything. - `cmd.Run`, `cmd.Output` and `cmd.CombinedOutput` all wait for you. The manual `Start`/`Wait` pair is only needed when you want to do something while the child runs — including killing it. ## The shape that gets it right When you start a command manually, pair the start with the wait immediately, and let the kill be a separate concern: ```go if err := cmd.Start(); err != nil { return err } defer cmd.Wait() // always reap, whatever else happens ``` In a program that kills deliberately, the more careful form captures the wait error, because that is where you learn what actually happened to the child — whether it exited on its own before your kill landed, or died from the signal. Using `exec.CommandContext` does not change any of this. Context cancellation triggers the kill; it does not reap. The package still expects your program to call `Wait`, and the whole cleanup path — descriptors, copying goroutines, the process-table entry — hangs off that call. ## Why this shows up in interviews It is a good question because it separates people who have used `cmd.Run` from people who have operated a program that starts processes. The `Run` path hides `Wait` entirely, so the requirement is invisible until you need to kill something mid-flight and reach for `Start` — and that is precisely the code path where forgetting it costs a host its PID space.
- What actually goes wrong in a long-running server that starts commands and never waits?Each finished child stays in the process table as a zombie holding a PID. They cost no CPU or memory, so the service looks healthy while the count climbs, until the host hits its PID limit and every fork on the machine fails — including from unrelated processes. It is a slow, shared-blast-radius failure, which is why reviewers look for the Start/Wait pairing.
- Does using exec.CommandContext remove the need to call cmd.Wait?No. The context triggers termination; it never reaps. All of the cleanup — collecting the exit status, closing the descriptors the package opened, finishing the goroutines copying output — happens in `Wait`. `cmd.Run`, `cmd.Output` and `cmd.CombinedOutput` call it for you; the manual `Start` path does not.
- What does os.ProcessState.ExitCode report for a child you killed?-1. A real exit code only exists when the program exited on its own; a process terminated by a signal never chose a status, so `ExitCode` returns -1 to say so. That is also what it returns if the process has not exited yet, which is another reason to read it only after `Wait` has returned.
Kill is dialling the number; Wait is staying on the line until someone confirms. Hanging up early leaves the record open at the exchange.
saying these in an interview costs you the question
- Believes cmd.Process.Kill blocks until the child has exited
- Thinks a killed child needs no reaping because it did not exit normally
- Deletes the working directory immediately after the kill
- Reads a bytes.Buffer assigned to cmd.Stdout before cmd.Wait returns
- Calls cmd.Wait twice and expects the same status back