[windows] c:= exec.CommandContext(ctx, ...) + c.Output() don't return when process is killed upon context expiration until child termination

317 views
Skip to first unread message

Pablo Caballero

unread,
Feb 4, 2022, 2:49:41 PM2/4/22
to golang-nuts

Hi, community!

The problem is happening in a more complex program but I’ve written a simple program to reproduce it.

package main import ( "context" "fmt" "os/exec" "time" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30000)*time.Millisecond) defer cancel() c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe") _, err := c.Output() fmt.Println("end", "err", err) }

Steps to reproduce:

  • 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)

Thank you in advance!

Best regards!

Pablo Caballero

unread,
Feb 4, 2022, 3:28:20 PM2/4/22
to golang-nuts

Errata

Go is blocked trying to read from the channel (exec\exec.go):

for range c.goroutine { if err := <-c.errch; err != nil && copyError == nil { copyError = err } }

I think that the problem happens if you specify an io.Writer as cmd.Stdout and such writer doesn’t satisfy os.File (Output() uses a bytes.Buffer internally) because in that case a Pipe is used (to copy data between the writer/process). 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 writes to the writer even if the parent process is gone).

I got a solution but not sure if it's good enough:

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()) }

Ian Lance Taylor

unread,
Feb 4, 2022, 3:42:00 PM2/4/22
to Pablo Caballero, golang-nuts
On Fri, Feb 4, 2022 at 12:30 PM Pablo Caballero <pdc...@gmail.com> wrote:
>
> Errata
>
> Go is blocked trying to read from the channel (exec\exec.go):
>
> for range c.goroutine { if err := <-c.errch; err != nil && copyError == nil { copyError = err } }
>
> I think that the problem happens if you specify an io.Writer as cmd.Stdout and such writer doesn’t satisfy os.File (Output() uses a bytes.Buffer internally) because in that case a Pipe is used (to copy data between the writer/process). 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 writes to the writer even if the parent process is gone).

For reference, this class of problems is https://go.dev/issue/23019.

Ian

Pablo Caballero

unread,
Feb 4, 2022, 6:23:45 PM2/4/22
to golang-nuts
Hi Ian! Thank you so much for sharing that link with me. It was very enriching to read it.

As I commented in my first message, the fact that the problem did not occur on Mac got me a bit confused at first.

Best!

nakul desai

unread,
Jul 25, 2023, 2:26:36 PM7/25/23
to golang-nuts
I ran into the same issue and am curious why this is observed only on windows and not mac/linux considering that the issue seems independent of the platform ? 

Bryan C. Mills

unread,
Jul 26, 2023, 12:22:37 PM7/26/23
to golang-nuts
On Friday, February 4, 2022 at 6:23:45 PM UTC-5 Pablo Caballero wrote:
Hi Ian! Thank you so much for sharing that link with me. It was very enriching to read it.

As I commented in my first message, the fact that the problem did not occur on Mac got me a bit confused at first.

macOS does not have a `cmd.exe` or `notepad.exe`. What were the actual steps you took to check the behavior there?
(My guess is that you were testing against a child program that does not hold stderr or stdout open.)

Either way, the solutions are to either use explicit pipes for I/O (and close them or stop reading them when you are confident the output is done), or (as of Go 1.20) set a nonzero WaitDelay on the exec.Cmd.
Reply all
Reply to author
Forward
0 new messages