Files

138 lines
4.6 KiB
Go

package shell
import (
"context"
"errors"
"fmt"
"os/exec"
"time"
"mvdan.cc/sh/v3/interp"
)
// processGroupExecHandler is the terminal middleware in the Crush exec
// handler chain. It owns the final hop into os/exec for every external
// command — bare or path-prefixed — so that on context cancellation we
// can signal the entire process tree, not just the direct child.
//
// This intentionally replaces mvdan.cc/sh's [interp.DefaultExecHandler],
// which uses exec.CommandContext + Process.Kill on the direct child only.
// Without this replacement, commands like `git rebase --continue` that
// fork an editor leak the editor when the tool call is cancelled. The
// middleware is the last entry in [standardHandlers] and never invokes
// next; mvdan's runtime auto-appends DefaultExecHandler after the user
// chain, and that fallback is exactly what we are trying to displace.
//
// PATH resolution mirrors [interp.LookPathDir] (the same primitive
// DefaultExecHandler uses), so users see identical resolution semantics
// regardless of which handler runs the binary.
func processGroupExecHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
if len(args) == 0 {
return next(ctx, args)
}
hc := interp.HandlerCtx(ctx)
path, err := interp.LookPathDir(hc.Dir, hc.Env, args[0])
if err != nil {
fmt.Fprintln(hc.Stderr, err)
return interp.ExitStatus(127)
}
return runExternal(ctx, path, args)
}
}
}
// runExternal launches path with the given argv under our process-group
// cancellation regime. argv[0] is preserved as the visible program name
// (matching how mvdan.cc/sh builds exec.Cmd); path is the already-resolved
// absolute binary.
//
// Cancellation flow on Unix:
// 1. context.AfterFunc fires when ctx is cancelled.
// 2. We send SIGINT to the process group (-pid).
// 3. After [killTimeout], we send SIGKILL to the process group.
//
// The two-stage signal lets well-behaved children clean up (close PTYs,
// flush buffers) before the kernel mows them down. killTimeout is 0 on
// platforms without SIGINT support; the closure collapses to a single
// kill in that case.
//
// On normal completion, defer stops the AfterFunc so we never signal a
// reaped pid.
func runExternal(ctx context.Context, path string, args []string) error {
hc := interp.HandlerCtx(ctx)
cmd := exec.Cmd{
Path: path,
Args: args,
Env: execEnvList(hc.Env),
Dir: hc.Dir,
Stdin: hc.Stdin,
Stdout: hc.Stdout,
Stderr: hc.Stderr,
}
prepareCmd(&cmd)
if err := cmd.Start(); err != nil {
// LookPathDir already validated existence, so a Start failure
// here is almost always a permission or fork error. Surface it
// the same way mvdan's DefaultExecHandler does — message on
// stderr, 127 exit code — to keep UX consistent with the case
// where LookPathDir itself fails.
fmt.Fprintln(hc.Stderr, err)
return interp.ExitStatus(127)
}
stopCancel := context.AfterFunc(ctx, func() {
if killTimeout <= 0 {
_ = killCmd(&cmd)
return
}
_ = interruptCmd(&cmd)
time.Sleep(killTimeout)
_ = killCmd(&cmd)
})
defer stopCancel()
waitErr := cmd.Wait()
return translateExitError(ctx, waitErr)
}
// translateExitError maps an exec.Cmd.Wait error into the form mvdan's
// interpreter expects:
//
// - nil → nil
// - cancellation → ctx.Err() (so callers can errors.Is for Canceled /
// DeadlineExceeded; matches IsInterrupt's contract)
// - non-zero exit → interp.ExitStatus(code), with signaled exits
// reported as 128 since we no longer have signal-specific info on
// all platforms
// - other errors → returned verbatim so the runner aborts
//
// The ctx.Err() check has to come first: when our AfterFunc kills the
// process, Wait returns an ExitError whose ExitCode is -1 (signaled). We
// want callers to see Canceled rather than a synthetic exit code, since
// the synthetic code can't be distinguished from a genuine SIGINT the
// user typed.
func translateExitError(ctx context.Context, err error) error {
if err == nil {
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
code := exitErr.ExitCode()
if code < 0 {
// Signaled but ctx wasn't cancelled — e.g. the user sent
// SIGTERM from outside. Report a non-zero status so the
// interpreter sees failure, without claiming a specific
// signal number we no longer track.
code = 128
}
return interp.ExitStatus(uint8(code))
}
return err
}