Just run the program and observe the process tree (using Process Explorer or similar)
Expected behavior: c.Output() call returns as soon as cmd.exe process is killed on context timeout.
Observed behavior: the program blocks “forever” on c.Output() call until I kill the notepad.exe process manually.
Debugging shows that Go is blocked on (os/exec_windows.go):
s, e := syscall.WaitForSingleObject(syscall.Handle(handle), syscall.INFINITE)
waiting on the cmd.exe process handle (even after it was killed).
I also tested on Mac (was curious about the behavior on non-windows OSes) and on Mac c.Output() returns as soon as the parent process is killed (children process keeps running but it doesn’t cause c.Output() call to block)
Output() calls Run() internally (I need to capture stdout). Run has the same problem if you specify an io.Writer as cmd.Stdout and such writer doesn’t satisfy os.File because in that case a Pipe is used. It seems like the pipe is inherited by the child process and isn’t closed until the child finish (blocking the goroutine that reads from the pipe and writer to the buffer).
I came to this solution:
package main
import (
"bytes"
"context"
"fmt"
"io"
"os/exec"
"time"
)
type WriterWithReadFrom interface {
io.Writer
io.ReaderFrom
}
type ContextWrappedWriter struct{
w WriterWithReadFrom
c context.Context
}
type ReadFromResult struct{
n int64
err error
}
func (cww *ContextWrappedWriter) Write(p []byte) (n int, err error){
return cww.Write(p)
}
func (cww *ContextWrappedWriter) ReadFrom(r io.Reader) (n int64, err error){
if c, ok := r.(io.Closer); ok {
ch := make(chan ReadFromResult, 1)
go func() {
n, err := cww.w.ReadFrom(r)
ch <- ReadFromResult{n, err}
}()
closed := false
for ;; {
select {
case res := <-ch:
return res.n, res.err
case <-cww.c.Done():
if !closed{
closed = true
err := c.Close()
if err != nil {
return 0, fmt.Errorf("error closing reader: %v", err)
}
}
time.Sleep(time.Second * 1)
}
}
} else {
return cww.w.ReadFrom(r)
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30000)*time.Millisecond)
defer cancel()
var Stdout, Stderr bytes.Buffer
c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
c.Stderr = &ContextWrappedWriter{&Stderr, ctx}
c.Stdout = &ContextWrappedWriter{&Stdout, ctx}
err := c.Run()
fmt.Println("end", "err", err, "stdout", Stdout.String(), "stderr", Stderr.String())
}