//go:build unix package hooks import ( "bytes" "context" "encoding/json" "errors" "fmt" "os/exec" "syscall" "github.com/steveyegge/beads/internal/types" ) // runHook executes the hook and enforces a timeout, killing the process group // on expiration to ensure descendant processes are terminated. func (r *Runner) runHook(hookPath, event string, issue *types.Issue) error { ctx, cancel := context.WithTimeout(context.Background(), r.timeout) defer cancel() // Prepare JSON data for stdin issueJSON, err := json.Marshal(issue) if err != nil { return err } // Create command: hook_script // #nosec G204 -- hookPath is from controlled .beads/hooks directory cmd := exec.CommandContext(ctx, hookPath, issue.ID, event) cmd.Stdin = bytes.NewReader(issueJSON) // Capture output for debugging (but don't block on it) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr // Start the hook so we can manage its process group and kill children on timeout. // // Rationale: scripts may spawn child processes (backgrounded or otherwise). // If we only kill the immediate process, descendants may survive and keep // the test (or caller) blocked — this was exposed by TestRunSync_Timeout and // validated by TestRunSync_KillsDescendants. Creating a process group (Setpgid) // and sending a negative PID to syscall.Kill ensures the entire group // (parent + children) are killed reliably on timeout. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if err := cmd.Start(); err != nil { return err } done := make(chan error, 1) go func() { done <- cmd.Wait() }() select { case <-ctx.Done(): if cmd.Process != nil { if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { return fmt.Errorf("kill process group: %w", err) } } // Wait for process to exit after the kill attempt <-done return ctx.Err() case err := <-done: if err != nil { return err } return nil } }