@@ -4,14 +4,26 @@ import (
44 "bytes"
55 "context"
66 "log/slog"
7− "os/exec"
87 "strings"
98 "sync"
109 "time"
1110
1211 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
1313)
1414
15+// abandonGrace is how long runOne waits after ctx cancellation for the
16+// shell goroutine to yield before returning control to the caller and
17+// letting the goroutine finish on its own. Mirrors the historical
18+// cmd.WaitDelay = time.Second behavior of the previous os/exec path.
19+const abandonGrace = time.Second
20+
21+// runShell is the shell executor used by runOne. It is a package-level
22+// variable so tests can substitute a blocking or non-yielding
23+// implementation to exercise the abandon-on-timeout path without
24+// depending on the scheduling behavior of the real interpreter.
25+var runShell = shell.Run
26+
1527// Runner executes hook commands and aggregates their results.
1628type Runner struct {
1729 hooks []config.HookConfig
@@ -102,24 +114,59 @@ func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
102114}
103115
104116// runOne executes a single hook command and returns its result.
117+//
118+// Execution goes through Crush's embedded POSIX shell (shell.Run) so the
119+// same interpreter, builtins, and coreutils are visible to hooks as to
120+// the bash tool. BlockFuncs are intentionally omitted: hooks are
121+// user-authored config that carry the same trust as a shell alias.
122+//
123+// A hook that fails to yield after its deadline has passed is abandoned
124+// after abandonGrace so the caller never blocks longer than
125+// timeout + abandonGrace. Ownership of the stdout and stderr buffers is
126+// strictly single-goroutine:
127+// - before receiving from `done`, only the goroutine writes to them;
128+// - after `done` delivers a value, the goroutine is finished and the
129+// outer frame reads them;
130+// - on the abandon path, the goroutine may still be writing and the
131+// outer frame must not touch them again.
105132func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
106133 timeout := hook.TimeoutDuration()
107134 ctx, cancel := context.WithTimeout(parentCtx, timeout)
108135 defer cancel()
109136
110− cmd := exec.CommandContext(ctx, "sh", "-c", hook.Command)
111− cmd.WaitDelay = time.Second
112− cmd.Env = envVars
113− cmd.Dir = r.cwd
114− cmd.Stdin = bytes.NewReader(payload)
115−
116137 var stdout, stderr bytes.Buffer
117− cmd.Stdout = &stdout
118− cmd.Stderr = &stderr
119−
120− err := cmd.Run()
138+ done := make(chan error, 1)
139+ go func() {
140+ done <- runShell(ctx, shell.RunOptions{
141+ Command: hook.Command,
142+ Cwd: r.cwd,
143+ Env: envVars,
144+ Stdin: bytes.NewReader(payload),
145+ Stdout: &stdout,
146+ Stderr: &stderr,
147+ })
148+ }()
149+
150+ var err error
151+ select {
152+ case err = <-done:
153+ // Normal path: goroutine has finished, buffers are safe to read.
154+ case <-ctx.Done():
155+ select {
156+ case err = <-done:
157+ // Interpreter yielded within the grace period; safe to read.
158+ case <-time.After(abandonGrace):
159+ slog.Warn("Hook did not yield after cancel; abandoning goroutine",
160+ "command", hook.Command,
161+ "timeout", timeout,
162+ )
163+ // The goroutine may still be writing to stdout/stderr; do
164+ // not read either buffer below this point.
165+ return HookResult{Decision: DecisionNone}
166+ }
167+ }
121168
122− if ctx.Err() != nil {
169+ if shell.IsInterrupt(err) {
123170 // Distinguish timeout from parent cancellation.
124171 if parentCtx.Err() != nil {
125172 slog.Debug("Hook cancelled by parent context", "command", hook.Command)
@@ -130,10 +177,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
130177 }
131178
132179 if err != nil {
133− exitCode := -1
134− if cmd.ProcessState != nil {
135− exitCode = cmd.ProcessState.ExitCode()
136− }
180+ exitCode := shell.ExitCode(err)
137181 switch exitCode {
138182 case 2:
139183 // Exit code 2 = block this tool call. Stderr is the reason.
@@ -162,6 +206,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
162206 "command", hook.Command,
163207 "exit_code", exitCode,
164208 "stderr", strings.TrimSpace(stderr.String()),
209+ "error", err,
165210 )
166211 return HookResult{Decision: DecisionNone}
167212 }