How no-mistakes shellenv Prevents Grandchild Process Leaks on Clean Exit
no-mistakes shellenv prevents grandchild process leaks by isolating every subprocess in its own process group and enforcing a 5-second wait-delay backstop that guarantees SIGKILL delivery to the entire process tree on exit.
The no-mistakes repository provides robust shell environment management for long-running daemons that spawn external commands. When subprocesses create their own children—grandchildren of the original process—traditional cleanup mechanisms often fail, leading to zombie processes that exhaust system memory. The shellenv package implements a multi-layered defense strategy in internal/shellenv/shell_command_unix.go that ensures complete process tree termination even during clean exits.
Process Group Isolation
The foundation of leak prevention begins with process group isolation in internal/shellenv/shell_command_unix.go.
Configuring the Process Group
When spawning a command, ConfigureShellCommand configures the SysProcAttr to create a new process group:
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
Setting Setpgid: true makes the spawned command the leader of a new process group where pgid == pid. This allows signals sent to the negative PID to reach every member of the group simultaneously, including any grandchildren spawned by the immediate child.
The Cancel Hook
The function also wires a custom cancellation handler to cmd.Cancel:
cmd.Cancel = func() error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
This ensures that context cancellation sends SIGKILL to the entire process group (-cmd.Process.Pid), not just the immediate child.
The Wait-Delay Backstop
Even with signal delivery, grandchildren can hang indefinitely by holding open the leader's stdout or stderr pipes.
Preventing Pipe Hangs
The shellenv package prevents this via cmd.WaitDelay, set to a default of 5 seconds on Unix systems. In internal/shellenv/shell_command_unix.go, the configuration guards against zero values:
defaultWaitDelay = 5 * time.Second
if cmd.WaitDelay == 0 {
cmd.WaitDelay = defaultWaitDelay
}
If a grandchild process survives the initial SIGKILL or holds a pipe open, Go's runtime forces closure of the pipes after the delay expires. This prevents cmd.Wait from blocking forever and guarantees the leader process can exit cleanly.
Guaranteed Reaping on Clean Exit
The critical mechanism for preventing leaks during normal termination is TerminateShellCommandGroup.
TerminateShellCommandGroup Implementation
Callers using StartShellCommand or the one-shot helpers defer this function after starting the process. Located in internal/shellenv/shell_command_unix.go, the implementation sends SIGKILL to the entire process group and safely ignores "no such process" errors:
func TerminateShellCommandGroup(cmd *exec.Cmd) error {
if cmd.Process == nil {
return nil
}
err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
if errors.Is(err, syscall.ESRCH) {
return nil // Already terminated
}
return err
}
Even after cmd.Wait returns successfully, the deferred call ensures any stray grandchildren are terminated. If no processes remain in the group, the Kill syscall returns ESRCH, which the function treats as success.
Practical Usage Patterns
Long-Running Commands
For daemon operations that spawn persistent subprocesses:
cmd := exec.CommandContext(ctx, "npm", "test")
shellenv.ConfigureShellCommand(cmd) // Sets pgid, wait-delay, cancel
if err := shellenv.StartShellCommand(cmd); err != nil {
return err
}
defer shellenv.TerminateShellCommandGroup(cmd) // Always reap the group
// ... read stdout/stderr ...
if err := cmd.Wait(); err != nil {
return err
}
The defer guarantees that even if cmd.Wait() returns cleanly, any orphaned grandchildren receive SIGKILL.
One-Shot Helpers
The package provides convenience wrappers that apply the same protection automatically. OutputShellCommand internally calls defaultShellCommandOutput in internal/shellenv/shellenv.go, which invokes ConfigureShellCommand before execution:
out, err := shellenv.OutputShellCommand(exec.Command("git", "rev-parse", "HEAD"))
This ensures the login-shell probe and other short-lived commands benefit from process-group isolation without manual configuration.
Context Cancellation
To abort running commands programmatically:
ctx, cancel := context.WithCancel(context.Background())
cmd := exec.CommandContext(ctx, "go", "test", "./...")
shellenv.ConfigureShellCommand(cmd)
go func() {
time.Sleep(2 * time.Second)
cancel() // Triggers cmd.Cancel → SIGKILL whole group
}()
err := cmd.Run() // Returns after entire group terminates
Cross-Platform Implementation
While Unix systems use process groups, the package maintains API compatibility across platforms. internal/shellenv/shell_command_windows.go implements protection via Windows Job Objects, while internal/shellenv/shell_command_other.go provides a no-op stub for unsupported platforms:
func ConfigureShellCommand(cmd *exec.Cmd) {
// No-op on platforms without process-group support
}
This ensures the high-level helpers compile and execute correctly regardless of the target operating system.
Summary
- Process groups:
ConfigureShellCommandsetsSetpgid: trueto isolate subprocess trees, enabling signal targeting via negative PID. - Kill-on-cancel: The
cmd.Cancelhook sendsSIGKILLto the entire group when contexts cancel. - Wait-delay protection: A 5-second
WaitDelaypreventscmd.Waitfrom hanging when grandchildren hold pipes open. - Reap-on-exit:
TerminateShellCommandGroupdefers ensure no processes survive clean exits, safely ignoringESRCHwhen groups are already empty. - Universal coverage: One-shot helpers like
OutputShellCommandautomatically apply these protections viadefaultShellCommandOutputininternal/shellenv/shellenv.go.
Frequently Asked Questions
What happens if a grandchild process ignores SIGKILL?
SIGKILL cannot be caught, blocked, or ignored by any process. However, if a grandchild is in an uninterruptible sleep state (D-state), the signal remains pending. The 5-second WaitDelay in ConfigureShellCommand forces closure of the stdout/stderr pipes after the timeout, allowing cmd.Wait to return and the deferred TerminateShellCommandGroup to complete. While the process may persist briefly in the kernel, it cannot outlive the pipe closure indefinitely.
How does the 5-second WaitDelay prevent indefinite hangs?
When a parent process exits, grandchildren holding open file descriptors (like the parent's stdout pipe) prevent the Go runtime from considering the exec.Cmd finished. By setting cmd.WaitDelay = 5 * time.Second, the runtime closes these pipes forcefully after the delay, breaking the dependency. This causes cmd.Wait to return immediately with an error indicating the pipes closed, rather than blocking forever.
Is grandchild leak prevention active on Windows?
Yes, though the mechanism differs. While internal/shellenv/shell_command_unix.go uses POSIX process groups and syscall.Kill, Windows systems use Job Objects in internal/shellenv/shell_command_windows.go to achieve equivalent process-tree termination. The public API (ConfigureShellCommand, TerminateShellCommandGroup) remains identical, ensuring portable leak prevention across operating systems.
Do I need to manually call TerminateShellCommandGroup when using helper functions?
No. When using StartShellCommand, you must defer TerminateShellCommandGroup manually. However, the one-shot helpers—RunShellCommand, OutputShellCommand, and CombinedOutputShellCommand—handle this automatically via defaultShellCommandOutput in internal/shellenv/shellenv.go. These helpers configure the command, execute it, and ensure proper cleanup without additional caller code.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →