on 175ce34 help sign in
crush/internal/shell/exec_unix.go
108 lines 3.3 kB history raw
1//go:build !windows
3package shell
5import (
6 "context"
7 "fmt"
8 "io"
9 "os/exec"
10 "syscall"
11 "time"
13 "mvdan.cc/sh/v3/interp"
16// defaultKillTimeout matches mvdan's DefaultExecHandler default. Extracted
17// so the coupling to upstream is explicit rather than a buried literal.
18const defaultKillTimeout = 2 * time.Second
20// isolateProcess sets SysProcAttr so the child runs in its own session,
21// fully detached from Crush's controlling terminal. This prevents shells
22// like zsh from grabbing the TTY for job control, which would cause
23// "suspended (tty input)" and corrupt Bubble Tea's terminal rendering.
24// It also prevents child processes from sending signals to Crush's process
25// group.
26func isolateProcess(cmd *exec.Cmd) {
27 if cmd.SysProcAttr == nil {
28 cmd.SysProcAttr = &syscall.SysProcAttr{}
29 }
30 cmd.SysProcAttr.Setsid = true
33// processGroupExecHandler returns an ExecHandlerFunc that replaces
34// interp.DefaultExecHandler with one that fully isolates child processes
35// from Crush's session and controlling terminal.
36//
37// Without this, shells like zsh that enable job control when sourcing
38// framework files will attempt to take over the TTY, causing SIGTTIN/SIGTTOU
39// signals and corrupting the parent terminal state.
40//
41// The implementation mirrors interp.DefaultExecHandler with two additions:
42// isolateProcess(&cmd) after construction, and negative-PID signal targeting
43// in the cancellation callback so the entire child process group is killed.
44func processGroupExecHandler(killTimeout time.Duration) interp.ExecHandlerFunc {
45 return func(ctx context.Context, args []string) error {
46 hc := interp.HandlerCtx(ctx)
47 path, err := interp.LookPathDir(hc.Dir, hc.Env, args[0])
48 if err != nil {
49 fmt.Fprintln(hc.Stderr, err)
50 return interp.ExitStatus(127)
51 }
53 cmd := exec.Cmd{
54 Path: path,
55 Args: args,
56 Env: execEnvList(hc.Env),
57 Dir: hc.Dir,
58 Stdin: hc.Stdin,
59 Stdout: hc.Stdout,
60 Stderr: hc.Stderr,
61 }
62 isolateProcess(&cmd)
64 err = cmd.Start()
65 if err == nil {
66 stopf := context.AfterFunc(ctx, func() {
67 if killTimeout <= 0 {
68 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
69 return
70 }
71 // Signal the child's process group (negative PID) so
72 // grandchildren also receive it.
73 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGINT)
74 time.Sleep(killTimeout)
75 _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
76 })
77 defer stopf()
79 err = cmd.Wait()
80 }
82 return exitStatusFromError(ctx, hc.Stderr, err)
83 }
86// exitStatusFromError translates an exec error into an interp exit status,
87// matching the conventions of interp.DefaultExecHandler. Extracted so the
88// platform-specific exec handler stays focused on isolation mechanics.
89func exitStatusFromError(ctx context.Context, stderr io.Writer, err error) error {
90 if err == nil {
91 return nil
92 }
93 switch err := err.(type) {
94 case *exec.ExitError:
95 if status, ok := err.Sys().(syscall.WaitStatus); ok && status.Signaled() {
96 if ctx.Err() != nil {
97 return ctx.Err()
98 }
99 return interp.ExitStatus(128 + uint8(status.Signal()))
100 }
101 return interp.ExitStatus(uint8(err.ExitCode()))
102 case *exec.Error:
103 fmt.Fprintf(stderr, "%v\n", err)
104 return interp.ExitStatus(127)
105 default:
106 return err
107 }