Open-source project
go-cmd/cmd avatar
go-cmd/cmd

go-cmd/Cmd: running external commands in Go without the StdoutPipe race

Non-blocking external commands in Go with streaming output

1,028 stars138 forksGoMIT

At a glance

What is it?
go-cmd/cmd is a small wrapper around os/exec.Cmd that makes external commands safe in concurrent, asynchronous programs, with channel-based start, real-time stdout and stderr, a Status struct readable from any goroutine, and reliable process group termination. It is MIT licensed and needs Go 1.20 or newer.
Who is it for?
go-cmd/cmd fits a Go service that shells out to long-running external tools and needs to read their output while they run, to stop them reliably, and to do both from many goroutines without racing. It is the wrong dependency for a program that runs a short command and waits for it, where os/exec with a plain Run is clearer.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 116 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Three methods, and the race they exist to avoid

go-cmd/cmd is a small wrapper around `os/exec.Cmd` that makes it safe and simple to run external commands in highly concurrent, asynchronous, real-time applications, and it works on Linux, macOS, and Windows. The whole API is three methods: `Start`, `Stop`, and `Status`.

The problem it solves is concrete and specific to Go. The common way to read a command's output while it runs is to call `StdoutPipe` and read from the returned `io.ReadCloser`. The project points out plainly that this works but is wrong, because it causes a race condition that `go test -race` detects, and the Go documentation itself says it is incorrect to call `Wait` before all reads from the pipe have completed, and equally incorrect to call `Run` when using `StdoutPipe`.

That is a nasty class of bug. It does not always fail, it fails differently on different machines, and the failure mode is a corrupted read or a deadlock rather than a clear error. The fix in the standard library is to set the `io.Writer` of `Stdout` instead, but doing that in a thread-safe way requires more work, because you now have to write while possibly N-many goroutines read. The project's claim is that it has done this work.

The alternative in the project is to make the mistake impossible rather than merely documented, which is the design choice worth noting. The three-method surface is small enough that reading the whole implementation is a reasonable afternoon, and the repository layout supports that: `cmd.go` holds the main implementation, with `cmd_linux.go`, `cmd_darwin.go`, `cmd_freebsd.go`, and `cmd_windows.go` for the platform-specific parts, plus `cmd_test.go` and separate Windows test files.

Start returns a channel, and the same call can block

The basic usage in the project summary is worth reading in full, because it shows the asynchronous and synchronous cases living side by side.

go
import (
	"fmt"
	"time"
	"github.com/go-cmd/cmd"
)

func main() {
	// Start a long-running process, capture stdout and stderr
	findCmd := cmd.NewCmd("find", "/", "--name", "needle")
	statusChan := findCmd.Start() // non-blocking

	ticker := time.NewTicker(2 * time.Second)

	// Print last line of stdout every 2s
	go func() {
		for range ticker.C {
			status := findCmd.Status()
			n := len(status.Stdout)
			fmt.Println(status.Stdout[n-1])
		}
	}()

	// Stop command after 1 hour
	go func() {
		<-time.After(1 * time.Hour)
		findCmd.Stop()
	}()

	// Check if command is done
	select {
	case finalStatus := <-statusChan:
		// done
	default:
		// no, still running
	}

	// Block waiting for command to exit, be stopped, or be killed
	finalStatus := <-statusChan
}

Starting a command immediately returns a channel to which the final status is sent when the command exits for any reason, so by default commands run asynchronously. The `select` with a `default` branch is the non-blocking check for whether the command has finished, and the final receive blocks until the command exits, is stopped, or is killed.

Running synchronously is possible and easy, as the project notes, and the form is two lines:

go
// Run foo and block waiting for it to exit
c := cmd.NewCmd("foo")
s := <-c.Start()

The fact that the synchronous case is expressed by receiving from the same channel is the point. There is one code path, and the difference between blocking and not blocking is whether you read the channel now or later. The project notes that achieving the same with `os/exec.Cmd` requires everything this package already does.

The example directory in the tree mirrors this split, with `examples/blocking-buffered/` and `examples/blocking-streaming/`, which is a sensible way to show the two consumption patterns.

Status is a struct any goroutine can read at any time

Real-time status is the third feature, and it is what makes the package useful in a service rather than in a script. `Status` can be called any time by any goroutine, and it returns this struct:

go
type Status struct {
    Cmd      string
    PID      int
    Complete bool
    Exit     int
    Error    error
    Runtime  float64 // seconds
    Stdout   []string
    Stderr   []string
}

Every field is there for a reason. `Runtime` gives you elapsed time without keeping your own timer. `Complete` distinguishes a finished command from a running one. `Exit` and `Error` are separate, which matters because a command that ran and failed is different from a command that could not be started at all, and a caller that only checks `Error` will mishandle the first case. `PID` is there for anyone who needs to identify the process elsewhere. `Stdout` and `Stderr` are slices of lines, so the ticker goroutine in the example can read the last line produced so far without touching the raw pipes.

The project also describes this as a complete and consolidated return. The point is that the Go built-in `Cmd` does not put all the return information in one place, and this package gathers it into a single struct so that every consumer sees the same picture. And there is a consistency guarantee: even after the command finishes, calling `Status` returns the final status, the same final status sent to the channel returned by the call to `Start`. So a poller and a channel receiver never disagree.

The `Stdout` and `Stderr` fields being slices of lines is worth noting for capacity planning. The package is designed for commands whose output you read and display, and a command that produces a very large volume of output is a different problem from the one this package addresses.

Stop kills the process group, which is the part that surprises people

Proper process termination is the fifth listed capability, and the explanation of why it matters is the most useful piece of the project summary.

The problem is that `os/exec.Cmd.Wait` can block even after the command has been killed. That can be surprising and cause problems, and the reason has to do with process group IDs. It is common to kill the command PID, but usually one needs to kill its process group ID instead, and the project's `Stop` implements the necessary low-level magic to make this happen.

The practical picture is a very common one. You start a tool that shells out further, or that starts a child of its own, and when you cancel it the direct child dies but its descendants keep running. Then your `Wait` blocks, because the pipe the descendant inherited is still open and there is still data to read from it. The application appears to hang on cancellation.

Killing the process group fixes that, because the descendants go with it and the pipes close. The cost of that approach is that it is more aggressive, so a caller who shares a process group with something else is in for a surprise, and the platform-specific files in the tree are where that logic lives, one per operating system.

The acknowledgements section explains a related piece of low-level detail. Brian Ip wrote the original code to get the exit status, and the project remarks that Go does not just provide this, it requires magic like `exiterr.Sys().(syscall.WaitStatus)`. Getting the real exit code of a child process rather than a generic failure is exactly the kind of detail that a wrapper earns its keep on.

A tiny module with one test dependency and an old Go floor

The module definition is three lines and one dependency:

code
module github.com/go-cmd/cmd

go 1.20

require github.com/go-test/deep v1.1.0

`github.com/go-test/deep` is a test dependency rather than something your application pulls in. The package itself has no runtime dependencies, which for a library that sits in your process's critical path is a genuine advantage: there is no supply chain surface, and no version conflict to negotiate with whatever else you import.

The Go floor of 1.20 is the one constraint worth planning around. The module declares that version, and the release history shows the last tagged version is v1.4.3 from 2024-06-23, with v1.4.2 and v1.4.1 both from 2023-07-08. The last push to the repository was on 2026-06-28, so the code is still being touched even though the last release tag is a couple of years old.

The licence is MIT, with the file at the root of the tree, and there is a `SECURITY.md` and a `CHANGELOG.md`. The `go.sum` file is present alongside the module definition, which is the expected pair for a Go library.

The project claims 100% test coverage with no race conditions, and states that in addition to that the package is actively used in production environments. The coverage claim is the project's own, and it is checkable, since the tests live in `cmd_test.go` and the Windows-specific ones in `cmd_windows_test.go` and `cmd_windows_internal_test.go`, with a further `test/` directory at the root.

Where this package is the wrong tool

The boundaries are sharp, and they come from what the package optimises for.

First, a short command that you run and wait for needs none of this. If you call `exec.Command` and `Run` and read the output afterwards, there is no race, no concurrency, and no process group to worry about. The package earns its keep on long-running processes and on programs that watch output while it arrives, and using it for a one-shot command adds a channel receive and a Status struct for no benefit.

Second, output is accumulated in memory. The `Stdout` and `Stderr` fields are slices of lines that the package holds, so a command that prints tens of megabytes is a memory problem, and a command with no line breaks at all is a single very large string in that slice. Neither is documented as a limitation, but it follows from the shape of the data, and it is the case to think about before pointing this at a verbose tool.

Third, `Stop` is a process group kill, and that is a sharp instrument. It is the right behaviour for cancelling a tool you started, but it means the caller has to be sure the child is not sharing a process group with anything it should not kill. In a service that starts many children, that is a real constraint on how you use it.

Fourth, the library is small and its surface is unlikely to grow. Three methods, one struct, and a status channel. If you need process control features this does not have, such as stdin writing, environment overrides per command, or a way to send signals other than stop, they are not here, and the answer would be to go back to `os/exec` and do that part yourself.

Fifth, the version cadence is slow. v1.4.1 and v1.4.2 landed on the same day in July 2023 and v1.4.3 came in June 2024, with commits continuing afterwards. For a package this small that is fine, but it does mean you should read the changelog before upgrading rather than assuming the latest tag contains what you expect.

Editorial conclusion

go-cmd/cmd fits a Go service that shells out to long-running external tools and needs to read their output while they run, to stop them reliably, and to do both from many goroutines without racing. It is the wrong dependency for a program that runs a short command and waits for it, where os/exec with a plain Run is clearer. Check three things before you take it: that your Go version is 1.20 or newer, since that is the module's declared floor and the only external dependency is a test helper, that you understand the package accumulates output in memory as lines, so a command that prints megabytes is a different problem from one that prints kilobytes, and that you handle the exit status from the Status struct rather than assuming a non-zero exit means the process was never started.

Frequently asked questions

What problem does go-cmd/cmd solve?

It is a small wrapper around `os/exec.Cmd` for running external commands in concurrent, asynchronous, real-time applications, avoiding the race condition that comes from reading a command's output through `StdoutPipe`. The package has three methods: `Start`, `Stop`, and `Status`.

How do I run a command asynchronously and get its output while it runs?

Call `cmd.NewCmd(...)` and then `Start`, which returns a channel that receives the final status when the command exits for any reason. While it runs, call `Status` from any goroutine to read the `Stdout` and `Stderr` line slices, the `Runtime`, and the `Complete` flag. A `select` with a `default` branch on the status channel checks without blocking.

Why does go-cmd/cmd have a Stop method?

Because `os/exec.Cmd.Wait` can block even after the command has been killed, which the project attributes to process group IDs. Killing the command PID is usually not enough, since killing its process group is required, and `Stop` implements that so termination is reliable.

What Go version and dependencies does go-cmd/cmd need?

The module declares `go 1.20` and its only requirement is `github.com/go-test/deep v1.1.0`. The package itself has no runtime dependencies, and it works on Linux, macOS, and Windows.

Official sources

  1. go-cmd/cmd on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/go-cmd-cmd.svg)](https://hysenlabs.com/projects/go-cmd-cmd)