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

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!

Errata

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

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

It looks like you aren’t calling Run() on the returned Cmd, as is done here exec package - os/exec - pkg.go.dev

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

Hi got is answered here:

https://groups.google.com/g/golang-nuts/c/xEHZo6x45s4

Best!

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.