Skip to content

Fixes zombie process leak in execFile - #39

Open
donatj wants to merge 10 commits into
masterfrom
fix/zombie-cleanup
Open

Fixes zombie process leak in execFile#39
donatj wants to merge 10 commits into
masterfrom
fix/zombie-cleanup

Conversation

@donatj

@donatj donatj commented Apr 24, 2026

Copy link
Copy Markdown
Owner
  • Defers cmd.Wait() immediately after cmd.Start() to ensure process reaping
  • Combines Wait error with any stdin operation errors using multierror
  • Moves timeout timer setup before stdin operations for proper cleanup

Previously early returns from Seek or Copy failures would skip cmd.Wait(), leaving zombie processes.

- Defers cmd.Wait() immediately after cmd.Start() to ensure process reaping
- Combines Wait error with any stdin operation errors using multierror
- Moves timeout timer setup before stdin operations for proper cleanup

Previously early returns from Seek or Copy failures would skip cmd.Wait(), leaving zombie processes.
@donatj
donatj marked this pull request as ready for review April 24, 2026 20:55
Copilot AI review requested due to automatic review settings April 24, 2026 20:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to ensure hook subprocesses started by HookExec.execFile are always reaped (avoiding zombie processes) even when stdin preparation/writes fail, while also improving timeout cleanup and error reporting.

Changes:

  • Switches execFile to a named return so cmd.Wait() can be deferred and its error combined with earlier failures.
  • Moves the timeout timer setup earlier in execFile to cover stdin operations and ensure cleanup on all exit paths.
  • Uses multierror to combine cmd.Wait() errors with stdin operation errors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread exec.go Outdated
Comment thread exec.go Outdated
Comment thread exec.go Outdated
- Uses exec.CommandContext with context.WithTimeout for process lifecycle
- Moves data.Seek before cmd.Start to avoid starting process on seek failure
- Removes deferred stdin.Close in favor of explicit cleanup on error paths
- Adds timeout exceeded detection with descriptive error message
- Maintains multierror combination of stdin and wait errors

Context-based cancellation is more idiomatic and ensures proper process cleanup on timeout.
Comment thread exec.go Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread exec.go Outdated
defer func() {
waitErr := cmd.Wait()

if ctx.Err() == context.DeadlineExceeded {

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout wrapping uses ctx.Err() == context.DeadlineExceeded unconditionally. In edge races where the deadline fires but the process exits cleanly (so cmd.Wait() returns nil), this would turn a successful run into an error (and also wraps a nil error). Consider only applying the timeout wrapper when waitErr != nil (or when the process was actually killed due to the context), e.g., gate on ctx.Err() == context.DeadlineExceeded && waitErr != nil / errors.Is(waitErr, context.DeadlineExceeded) / an ExitError signal check.

Suggested change
if ctx.Err() == context.DeadlineExceeded {
if waitErr != nil && ctx.Err() == context.DeadlineExceeded {

Copilot uses AI. Check for mistakes.
Comment thread exec.go
…/copy-error paths

Agent-Logs-Url: https://github.com/donatj/hookah/sessions/9c696600-1506-4502-9912-17bc6b4bb88d

Co-authored-by: donatj <133747+donatj@users.noreply.github.com>
donatj added 3 commits April 24, 2026 16:28
multierror.Append already handles nil cases
- Sets Setpgid to create process group for spawned scripts
- Monitors context deadline and explicitly kills process group on timeout
- Uses negative PID with SIGKILL to terminate all children

Shell scripts spawn child processes that survive parent termination. CommandContext kills only the parent shell,
leaving children like sleep running. Process groups allow killing the entire tree.
- Overrides cmd.Cancel to kill process group on context timeout
- Removes manual select/goroutine timeout monitoring
- Returns to simple cmd.Wait in defer

CommandContext automatically calls cmd.Cancel on timeout. Overriding it to kill the process group is cleaner
than manually monitoring ctx.Done.
Comment thread exec.go
}
defer cancel()

cmd := exec.CommandContext(ctx, f)
donatj added 4 commits April 24, 2026 17:04
- Guards cmd.Cancel against nil process
- Ignores stdin.Close error after successful copy

Child processes may exit early without reading stdin, causing close to fail with a broken pipe error.
- Switches from multierror.Append().ErrorOrNil() to native errors.Join
- Completes migration to stdlib error handling

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (4)

exec_test.go:186

  • Same as above: check the io.WriteString error so a temp-file write failure is reported at the source of the problem.
	_, _ = io.WriteString(f, "#!/bin/sh\ncat\n")
	require.NoError(t, f.Close())

exec.go:211

  • SysProcAttr.Setpgid and the process-group Cancel handler are set even when timeout == 0, which changes signal/foreground-process-group behavior for hooks that run without a timeout compared to the prior exec.Command implementation. If process-group management is only needed for enforcing timeouts, consider enabling it only when timeout > 0 to avoid this behavior change.
	cmd := exec.CommandContext(ctx, f)
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	cmd.Cancel = func() error {

exec_test.go:176

  • This test asserts the timeout behavior, but if execFile regresses and hangs, the test itself will hang (since it calls execFile synchronously). Wrapping the call in a goroutine with a select/time.After (similar to TestExecFileCopyError) makes the test fail fast instead of stalling the whole suite.
	start := time.Now()
	err = h.execFile(f.Name(), data, 200*time.Millisecond)
	elapsed := time.Since(start)

	require.Error(t, err)

exec_test.go:160

  • Ignoring the io.WriteString error can make failures harder to diagnose (e.g., if the temp file write fails, the test will fail later in a less direct way). Capture and assert the write error explicitly.

This issue also appears on line 185 of the same file.

	_, _ = io.WriteString(f, "#!/bin/sh\nsleep 30\n")
	require.NoError(t, f.Close())

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants