@@ -4,14 +4,26 @@ import (
12 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
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
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
15// Runner executes hook commands and aggregates their results.
27// Runner executes hook commands and aggregates their results.
16type Runner struct {
28type Runner struct {
17 hooks []config.HookConfig
29 hooks []config.HookConfig
@@ -102,24 +114,59 @@ func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
104// runOne executes a single hook command and returns its result.
116// runOne executes a single hook command and returns its result.
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.
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.
105func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
132func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
106 timeout := hook.TimeoutDuration()
133 timeout := hook.TimeoutDuration()
107 ctx, cancel := context.WithTimeout(parentCtx, timeout)
134 ctx, cancel := context.WithTimeout(parentCtx, timeout)
108 defer cancel()
135 defer cancel()
110− cmd := exec.CommandContext(ctx, "sh", "-c", hook.Command)
111− cmd.WaitDelay = time.Second
114− cmd.Stdin = bytes.NewReader(payload)
116 var stdout, stderr bytes.Buffer
137 var stdout, stderr bytes.Buffer
138+ done := make(chan error, 1)
140+ done <- runShell(ctx, shell.RunOptions{
141+ Command: hook.Command,
144+ Stdin: bytes.NewReader(payload),
117− cmd.Stdout = &stdout
145+ Stdout: &stdout,
118− cmd.Stderr = &stderr
146+ Stderr: &stderr,
120− err := cmd.Run()
148+ }()
153+ // Normal path: goroutine has finished, buffers are safe to read.
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,
163+ // The goroutine may still be writing to stdout/stderr; do
164+ // not read either buffer below this point.
165+ return HookResult{Decision: DecisionNone}
122− if ctx.Err() != nil {
169+ if shell.IsInterrupt(err) {
123 // Distinguish timeout from parent cancellation.
170 // Distinguish timeout from parent cancellation.
124 if parentCtx.Err() != nil {
171 if parentCtx.Err() != nil {
125 slog.Debug("Hook cancelled by parent context", "command", hook.Command)
172 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
132 if err != nil {
179 if err != nil {
133− exitCode := -1
180+ exitCode := shell.ExitCode(err)
134− if cmd.ProcessState != nil {
135− exitCode = cmd.ProcessState.ExitCode()
137 switch exitCode {
181 switch exitCode {
139 // Exit code 2 = block this tool call. Stderr is the reason.
183 // 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
162 "command", hook.Command,
206 "command", hook.Command,
163 "exit_code", exitCode,
207 "exit_code", exitCode,
164 "stderr", strings.TrimSpace(stderr.String()),
208 "stderr", strings.TrimSpace(stderr.String()),
166 return HookResult{Decision: DecisionNone}
211 return HookResult{Decision: DecisionNone}