Why does a child started with os/exec hang forever when you write to cmd.StdinPipe() but never close it?
answer
- the child is still reading
- what makes a read return zero bytes
- who holds the other write end
- cmd.Wait would close it, eventually
- close before Wait, not after
basics
~20 sFilters 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.
solid answer
~50 s`cmd.StdinPipe()` returns an `io.WriteCloser` that is the parent's write end of an operating-system pipe; the child holds the read end. A child that consumes all of its input before producing a result — a compressor, `sort`, `wc` — keeps reading until it sees end of file, and the kernel delivers that only when the last write end has been closed. Your program holds one, so if you never call `Close` on the writer, the child waits for more input, `cmd.Wait` waits for the child, and the whole process tree sits still. `cmd.Wait` does close the pipe, but only after it sees the command exit, which is exactly the wrong way round here — the caller has to close it sooner. Setting `cmd.Stdin` to a reader instead avoids the trap entirely: os/exec copies from it and closes the pipe when the copy reaches end of file.
code
go · 15 linesstdin, err := cmd.StdinPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
if _, err := io.Copy(stdin, payload); err != nil {
stdin.Close()
return err
}
if err := stdin.Close(); err != nil {
return err
}
return cmd.Wait()go deeper
Remember that a child reading standard input waits until it sees end of file, and that end of file on a pipe comes from closing the write end, not from the parent simply pausing.
Explain the ownership: your program holds the write end returned by cmd.StdinPipe, so you must Close it, and cmd.Wait closing it after the child exits arrives far too late to help.
Demonstrate the habit of closing stdin in the same goroutine that writes it, covering the error path, and of preferring cmd.Stdin set to a reader whenever the input is already in hand.
Own the convention that every subprocess call site has a defined input-termination story, and decide whether the team wraps os/exec so nobody hand-rolls the write-then-close sequence again.
## The child is not waiting for you to stop; it is waiting for a close When you hand a child process its input through `cmd.StdinPipe()`, you get back an `io.WriteCloser`. That value is the parent's write end of an operating-system pipe. The child process holds the matching read end as its file descriptor 0. A great many command-line programs are *filters*: they read their standard input until it ends and only then finish. `sort` cannot emit its first line until it has seen the last one. `wc` cannot report a count until counting stops. A compressor may emit output as it goes, but it will not exit until its input is done. "Input is done" has exactly one meaning on a pipe: a read returns zero bytes, which the kernel does only once **every** write end of that pipe has been closed. Your program is holding a write end. Until you close it, the child's read blocks. The child does not exit. `cmd.Wait` — which waits for exactly that — does not return. And `cmd.Wait` is the thing that would eventually close the pipe for you, so the two sides are waiting on each other. The program does not crash, does not log anything, and does not use CPU; it simply stops. ## The fix, and where to put the Close ```go stdin, err := cmd.StdinPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } if _, err := io.Copy(stdin, payload); err != nil { stdin.Close() return err } if err := stdin.Close(); err != nil { // the child now sees end of file return err } return cmd.Wait() ``` Two details matter about placement. **Close before `cmd.Wait`, not after.** A `defer stdin.Close()` at the top of the function looks tidy and is a classic version of this bug: deferred calls run when the function returns, which is *after* `cmd.Wait` has already blocked forever. The close has to happen on the path, or inside the goroutine that does the writing. **Close on the error path too.** If the copy fails halfway and you return without closing, you leak the same hang into whatever cleanup follows. A redundant `Close` is harmless: the writer os/exec hands you closes once, so if `cmd.Wait` later tries again nothing bad happens, and your own second call is a no-op rather than an error you must reason about. ## The shape that removes the trap If the input is already in hand — a byte slice, a string, an open file — do not take the pipe at all: ```go cmd.Stdin = bytes.NewReader(payload) ``` When `cmd.Stdin` is a reader that is not an `*os.File`, os/exec creates the pipe itself, starts a goroutine copying your reader into it, and closes the write end when that copy reaches end of file. The child gets its end-of-file automatically. When `cmd.Stdin` is an `*os.File`, that descriptor is passed straight through and there is no pipe of os/exec's making at all. And when `cmd.Stdin` is nil the child reads from the null device, so it sees end of file immediately — which is why a forgotten `cmd.Stdin` makes filters return empty output rather than hang. Take `cmd.StdinPipe()` when the input is *generated* rather than already available: you are streaming records as you compute them, forwarding another stream, or writing a request and then waiting on a reply. ## The second hang hiding behind the first Closing stdin is necessary but not always sufficient. If the child produces substantial output while you are still writing its input, you must be draining that output at the same time, because the child blocks once its output pipe fills and then stops reading your input. The usual safe arrangement is: one goroutine writes the payload and closes stdin with a deferred `Close`, while another goroutine (or os/exec's own, if you assigned a writer to `cmd.Stdout`) reads the output. Get both right and the pipeline runs; get only the close right and a large payload can still stall. ## How it looks when it happens The symptom is a process that hangs with no error, often only for certain inputs. A goroutine dump — from `go test` hitting its `-timeout`, or from sending SIGQUIT to the stuck binary — shows the parent parked in `cmd.Wait`, and the child alive in the process table doing nothing. Seeing `Wait` on the stack with a live child is the tell: ask what the child is still waiting for, and end of file on standard input is the first candidate.
- Is a deferred stdin.Close() at the top of the function good enough?No, and it is the most common form of this bug. A deferred call runs when the function returns, which is after cmd.Wait has already blocked waiting for a child that is still waiting for end of file. Close on the path before calling Wait, or defer it inside the goroutine that does the writing.
- What if the child produces output while you are still feeding cmd.StdinPipe()?Then closing stdin is necessary but not sufficient: the child blocks once its output pipe fills and stops reading your input, so writing and draining have to happen concurrently. Write and close stdin on one goroutine while another reads the output, or assign a writer to cmd.Stdout and let os/exec drain it.
- What does the child see if you leave cmd.Stdin nil?It reads from the null device, so its very first read returns end of file. A filter then produces empty output and exits immediately rather than hanging, which is why a forgotten cmd.Stdin shows up as a suspiciously empty result rather than a stall.
saying these in an interview costs you the question
- Expects cmd.Wait to close stdin in time to unblock the child
- Thinks the child sees end of file when the parent stops writing
- Defers the stdin Close until after cmd.Wait
- Believes writing a sentinel byte ends the input
- Assumes a nil cmd.Stdin shares the parent's standard input