mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
138 lines
4.6 KiB
Go
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
|
|
}
|