package tools import ( "bufio" "errors" "fmt" "io" "os/exec" "sync" "time" ) // group is whatever a platform offers for stopping a command *and everything // it started*: a process group on Unix, a job object on Windows. It is what // Stop acts on, because killing the shell alone leaves its children running // and holding the output pipe. type group interface { // kill ends the command and its descendants. kill() // release gives back whatever the group held, once the command has ended. release() } // stopGrace is how long Wait keeps reading after the command has been killed. // // Killing a process group should close the output pipe at once, so this is the // backstop for anything that escaped it — a process that changed its own group, // or a platform with no groups at all. Without it, one stray grandchild holding // the pipe leaves the reading goroutine blocked for as long as it lives. const stopGrace = 2 * time.Second // maxLines is how much of a command's output is kept. // // A runaway command can print without end, and a dialog holding all of it would // grow until the editor did. Ten thousand lines is far past what anyone reads // and far short of what hurts; past it the oldest go, because the tail of a // failing build is the part that matters. const maxLines = 10000 // Run is a command in flight, and then the record of one that has finished. // // Its output is read on a goroutine of its own, so everything the caller can // see is behind a lock. // // run, err := tools.Start("go test ./...", ".", wakeTheEventLoop) // if err != nil { // return err // } // if finished, code := run.Done(); finished { // fmt.Println(code, run.Lines()) // } type Run struct { command string mu sync.Mutex lines []string dropped int finished bool exit int process *exec.Cmd group group onLine func() } // Start runs a shell command in a directory and returns it in flight. // // Standard output and standard error are merged, in the order the command // wrote them, because a compiler's errors and its progress belong together. // // onLine is called whenever there is more to see and once when the command // ends. It runs on the reading goroutine, so it must only wake an event loop — // never touch what that loop owns. It is a parameter rather than a field // because this starts the goroutine that calls it, and a field assigned // afterwards would be a data race. func Start(command, dir string, onLine func()) (*Run, error) { process := exec.Command(Shell(), ShellArgs(command)...) process.Dir = dir process.SysProcAttr = childAttributes(process, command) process.WaitDelay = stopGrace output, err := process.StdoutPipe() if err != nil { return nil, fmt.Errorf("running %s: %w", command, err) } process.Stderr = process.Stdout if err := process.Start(); err != nil { return nil, fmt.Errorf("running %s: %w", command, err) } r := &Run{command: command, process: process, group: newGroup(process.Process), onLine: onLine} go r.read(output) return r, nil } // Command returns the command line being run. func (r *Run) Command() string { return r.command } // read collects the output until the command has gone. func (r *Run) read(output io.Reader) { scanner := bufio.NewScanner(output) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { r.append(scanner.Text()) r.notify() } r.finish() } // append adds one line, dropping the oldest once there are too many. func (r *Run) append(line string) { r.mu.Lock() defer r.mu.Unlock() if len(r.lines) >= maxLines { r.lines = r.lines[1:] r.dropped++ } r.lines = append(r.lines, line) } // finish waits for the command and records how it went. // // Why it failed does not matter to the caller beyond the code: a command that // exited non-zero and one that could not be waited for both mean "this did not // work", and the output already says which. func (r *Run) finish() { code := 0 if err := r.process.Wait(); err != nil { var exit *exec.ExitError if errors.As(err, &exit) { code = exit.ExitCode() } else { code = -1 } } r.group.release() r.mu.Lock() r.finished, r.exit = true, code r.mu.Unlock() r.notify() } // notify tells the caller there is something new. func (r *Run) notify() { if r.onLine != nil { r.onLine() } } // Lines returns what the command has printed so far, oldest first. // // The slice is a copy, so a caller can hold it while the command goes on // writing. func (r *Run) Lines() []string { r.mu.Lock() defer r.mu.Unlock() out := make([]string, len(r.lines)) copy(out, r.lines) return out } // Dropped returns how many lines were thrown away for being too old, which is // zero for any command anyone would run on purpose. func (r *Run) Dropped() int { r.mu.Lock() defer r.mu.Unlock() return r.dropped } // Done reports whether the command has ended, and with what exit code. // // The code is meaningless until finished is true. // // if finished, code := run.Done(); finished && code != 0 { // // it failed // } func (r *Run) Done() (finished bool, exit int) { r.mu.Lock() defer r.mu.Unlock() return r.finished, r.exit } // Stop kills the command, which is what closing a dialog while it is still // running does. // // A command that has already ended is left alone, so stopping twice is safe. func (r *Run) Stop() { if finished, _ := r.Done(); finished { return } r.group.kill() }