Files
beads/internal/hooks/hooks.go
Ross Gardler 1465f7bb1d hooks: kill descendant processes on timeout; add test and comment
Ensure RunSync kills the hook process group on timeout so descendant processes (e.g. scripts that ) cannot keep the caller blocked. Add explanatory comment in  and a Linux-only unit test  in . Trim pid parsing newline in the test. All tests pass locally.
2025-12-16 21:57:29 -08:00

188 lines
4.4 KiB
Go

// Package hooks provides a hook system for extensibility.
// Hooks are executable scripts in .beads/hooks/ that run after certain events.
package hooks
import (
"bytes"
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"github.com/steveyegge/beads/internal/types"
)
// Event types
const (
EventCreate = "create"
EventUpdate = "update"
EventClose = "close"
EventMessage = "message"
)
// Hook file names
const (
HookOnCreate = "on_create"
HookOnUpdate = "on_update"
HookOnClose = "on_close"
HookOnMessage = "on_message"
)
// Runner handles hook execution
type Runner struct {
hooksDir string
timeout time.Duration
}
// NewRunner creates a new hook runner.
// hooksDir is typically .beads/hooks/ relative to workspace root.
func NewRunner(hooksDir string) *Runner {
return &Runner{
hooksDir: hooksDir,
timeout: 10 * time.Second,
}
}
// NewRunnerFromWorkspace creates a hook runner for a workspace.
func NewRunnerFromWorkspace(workspaceRoot string) *Runner {
return NewRunner(filepath.Join(workspaceRoot, ".beads", "hooks"))
}
// Run executes a hook if it exists.
// Runs asynchronously - returns immediately, hook runs in background.
func (r *Runner) Run(event string, issue *types.Issue) {
hookName := eventToHook(event)
if hookName == "" {
return
}
hookPath := filepath.Join(r.hooksDir, hookName)
// Check if hook exists and is executable
info, err := os.Stat(hookPath)
if err != nil || info.IsDir() {
return // Hook doesn't exist, skip silently
}
// Check if executable (Unix)
if info.Mode()&0111 == 0 {
return // Not executable, skip
}
// Run asynchronously
go r.runHook(hookPath, event, issue)
}
// RunSync executes a hook synchronously and returns any error.
// Useful for testing or when you need to wait for the hook.
func (r *Runner) RunSync(event string, issue *types.Issue) error {
hookName := eventToHook(event)
if hookName == "" {
return nil
}
hookPath := filepath.Join(r.hooksDir, hookName)
// Check if hook exists and is executable
info, err := os.Stat(hookPath)
if err != nil || info.IsDir() {
return nil // Hook doesn't exist, skip silently
}
if info.Mode()&0111 == 0 {
return nil // Not executable, skip
}
return r.runHook(hookPath, event, issue)
}
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 <issue_id> <event_type>
// #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 — see TestRunSync_Timeout which previously
// observed a `sleep 60` still running after the parent process was killed.
// 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():
// Kill the whole process group to ensure any children (e.g., sleep)
// are also terminated.
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
// Wait for process to exit
<-done
return ctx.Err()
case err := <-done:
if err != nil {
return err
}
return nil
}
}
// HookExists checks if a hook exists for an event
func (r *Runner) HookExists(event string) bool {
hookName := eventToHook(event)
if hookName == "" {
return false
}
hookPath := filepath.Join(r.hooksDir, hookName)
info, err := os.Stat(hookPath)
if err != nil || info.IsDir() {
return false
}
return info.Mode()&0111 != 0
}
func eventToHook(event string) string {
switch event {
case EventCreate:
return HookOnCreate
case EventUpdate:
return HookOnUpdate
case EventClose:
return HookOnClose
case EventMessage:
return HookOnMessage
default:
return ""
}
}