diff --git a/interp/api.go b/interp/api.go index e785d48b..2002689c 100644 --- a/interp/api.go +++ b/interp/api.go @@ -126,6 +126,10 @@ type Runner struct { // such as the condition in a [syntax.IfClause]. noErrExit bool + // killProcessGroup controls whether to kill the entire process group + // instead of only a single process when terminating subprocesses. + killProcessGroup bool + // The current and last exit statuses. They can only be different if // the interpreter is in the middle of running a statement. In that // scenario, 'exit' is the status for the current statement being run, @@ -348,10 +352,13 @@ func Dir(path string) RunnerOption { func Interactive(enabled bool) RunnerOption { return func(r *Runner) error { r.opts[optExpandAliases] = enabled + r.killProcessGroup = !enabled return nil } } + + // Params populates the shell options and parameters. For example, Params("-e", // "--", "foo") will set the "-e" option and the parameters ["foo"], and // Params("+e") will unset the "-e" option and leave the parameters untouched. @@ -822,6 +829,8 @@ func (r *Runner) Reset() { dirStack: r.dirStack[:0], usedNew: r.usedNew, + + killProcessGroup: r.killProcessGroup, } // Ensure we stop referencing any pointers before we reuse bgProcs. clear(r.bgProcs) @@ -995,6 +1004,8 @@ func (r *Runner) subshell(background bool) *Runner { lastExit: r.lastExit, origStdout: r.origStdout, // used for process substitutions + + killProcessGroup: r.killProcessGroup, } r2.writeEnv = newOverlayEnviron(r.writeEnv, background) // Funcs are copied, since they might be modified. diff --git a/interp/handler.go b/interp/handler.go index a60f6fd8..59294e4e 100644 --- a/interp/handler.go +++ b/interp/handler.go @@ -138,23 +138,29 @@ func DefaultExecHandler(killTimeout time.Duration) ExecHandlerFunc { Stdout: hc.Stdout, Stderr: hc.Stderr, } + prepareCommand(&cmd, hc.runner.killProcessGroup) err = cmd.Start() if err == nil { - stopf := context.AfterFunc(ctx, func() { - if killTimeout <= 0 || runtime.GOOS == "windows" { - _ = cmd.Process.Signal(os.Kill) - return - } - _ = cmd.Process.Signal(os.Interrupt) - // TODO: don't sleep in this goroutine if the program - // stops itself with the interrupt above. - time.Sleep(killTimeout) - _ = cmd.Process.Signal(os.Kill) - }) - defer stopf() - - err = cmd.Wait() + if err = postStartCommand(&cmd, hc.runner.killProcessGroup); err == nil { + stopf := context.AfterFunc(ctx, func() { + pg := hc.runner.killProcessGroup + if killTimeout <= 0 { + _ = killCommand(&cmd, pg) + return + } + _ = interruptCommand(&cmd, pg) + // TODO: don't sleep in this goroutine if the program + // stops itself with the interrupt above. + time.Sleep(killTimeout) + _ = killCommand(&cmd, pg) + }) + defer stopf() + + err = cmd.Wait() + } else { + _ = cmd.Wait() + } } switch err := err.(type) { diff --git a/interp/os_notunix.go b/interp/os_notunix.go index e7ca682a..4c8ce9f2 100644 --- a/interp/os_notunix.go +++ b/interp/os_notunix.go @@ -1,13 +1,14 @@ // Copyright (c) 2017, Andrey Nering // See LICENSE for licensing information -//go:build !unix +//go:build !unix && !windows package interp import ( "context" "fmt" + "os/exec" "mvdan.cc/sh/v3/syntax" ) @@ -56,3 +57,19 @@ type waitStatus struct{} func (waitStatus) Signaled() bool { return false } func (waitStatus) Signal() int { return 0 } + +// prepareCommand is a no-op. +func prepareCommand(cmd *exec.Cmd, processGroup bool) {} + +// postStartCommand is a no-op. +func postStartCommand(cmd *exec.Cmd, processGroup bool) error { return nil } + +// interruptCommand interrupts the process by killing it. +func interruptCommand(cmd *exec.Cmd, processGroup bool) error { + return cmd.Process.Kill() +} + +// killCommand kills the process by killing it. +func killCommand(cmd *exec.Cmd, processGroup bool) error { + return cmd.Process.Kill() +} diff --git a/interp/os_unix.go b/interp/os_unix.go index 214f7e0f..b57224e6 100644 --- a/interp/os_unix.go +++ b/interp/os_unix.go @@ -7,6 +7,7 @@ package interp import ( "context" + "os/exec" "os/user" "strconv" "syscall" @@ -46,3 +47,30 @@ func (r *Runner) unTestOwnOrGrp(ctx context.Context, op syntax.UnTestOperator, x } type waitStatus = syscall.WaitStatus + +// prepareCommand sets the SysProcAttr for the command. +// If processGroup is true, a new process group is created. +func prepareCommand(cmd *exec.Cmd, processGroup bool) { + if processGroup { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } +} + +// postStartCommand is a no-op on Unix. +func postStartCommand(cmd *exec.Cmd, processGroup bool) error { return nil } + +// interruptCommand sends SIGINT to the process or its process group. +func interruptCommand(cmd *exec.Cmd, processGroup bool) error { + if processGroup { + return unix.Kill(-cmd.Process.Pid, unix.SIGINT) + } + return cmd.Process.Signal(syscall.SIGINT) +} + +// killCommand sends SIGKILL to the process or its process group. +func killCommand(cmd *exec.Cmd, processGroup bool) error { + if processGroup { + return unix.Kill(-cmd.Process.Pid, unix.SIGKILL) + } + return cmd.Process.Kill() +} diff --git a/interp/os_windows.go b/interp/os_windows.go new file mode 100644 index 00000000..53c5294f --- /dev/null +++ b/interp/os_windows.go @@ -0,0 +1,229 @@ +// Copyright (c) 2017, Andrey Nering +// See LICENSE for licensing information + +//go:build windows + +package interp + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + "mvdan.cc/sh/v3/syntax" +) + +func mkfifo(path string, mode uint32) error { + return fmt.Errorf("unsupported") +} + +// access attempts to emulate [unix.Access] on Windows. +// Windows seems to have a different system of permissions than Unix, +// so for now just rely on what [io/fs.FileInfo] gives us. +func (r *Runner) access(ctx context.Context, path string, mode uint32) error { + info, err := r.lstat(ctx, path) + if err != nil { + return err + } + m := info.Mode() + switch mode { + case access_R_OK: + if m&0o400 == 0 { + return fmt.Errorf("file is not readable") + } + case access_W_OK: + if m&0o200 == 0 { + return fmt.Errorf("file is not writable") + } + case access_X_OK: + if m&0o100 == 0 { + return fmt.Errorf("file is not executable") + } + } + return nil +} + +// unTestOwnOrGrp panics. Under Unix, it implements the -O and -G unary tests, +// but under Windows, it's unclear how to implement those tests, since Windows +// doesn't have the concept of a file owner, just ACLs, and it's unclear how +// to map the one to the other. +func (r *Runner) unTestOwnOrGrp(ctx context.Context, op syntax.UnTestOperator, x string) bool { + panic(fmt.Sprintf("unhandled unary test op: %v", op)) +} + +// waitStatus is a no-op on windows. +type waitStatus struct{} + +func (waitStatus) Signaled() bool { return false } +func (waitStatus) Signal() int { return 0 } + +// jobEntry holds the job object handle and a flag indicating whether it has +// already been terminated, so that the cleanup goroutine and killCommand / +// interruptCommand do not race to close the same handle. +type jobEntry struct { + mu sync.Mutex + handle windows.Handle + closed bool +} + +// terminate terminates the job object and closes the handle exactly once. +// It is safe to call from multiple goroutines concurrently. +// When called from the cleanup goroutine after natural process exit, the +// TerminateJobObject call is a no-op because the job is already empty. +func (e *jobEntry) terminate(exitCode uint32) error { + e.mu.Lock() + defer e.mu.Unlock() + if e.closed { + return nil + } + e.closed = true + err := windows.TerminateJobObject(e.handle, exitCode) + windows.CloseHandle(e.handle) + return err +} + +// jobHandles maps process IDs to their *jobEntry. +var jobHandles sync.Map + +// ntResumeProcess is the NtResumeProcess syscall from ntdll.dll. +// Unlike ResumeThread (which requires a thread handle), NtResumeProcess +// accepts a process handle and resumes all suspended threads in the process. +// The required access right is PROCESS_SUSPEND_RESUME. +var ntResumeProcess = windows.NewLazyDLL("ntdll.dll").NewProc("NtResumeProcess") + +// prepareCommand sets the SysProcAttr for the command. +// If processGroup is true, the process is created suspended and will be +// assigned to a job object in postStartCommand before being resumed. +func prepareCommand(cmd *exec.Cmd, processGroup bool) { + if processGroup { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_SUSPENDED | windows.CREATE_NEW_PROCESS_GROUP, + } + } +} + +// postStartCommand assigns the process to a job object if processGroup is true, +// then resumes the process. This must be called immediately after cmd.Start(). +// +// The process is started suspended (via CREATE_SUSPENDED in prepareCommand) to +// eliminate the race between process start and job object assignment: the process +// cannot spawn children until it is resumed, so all descendants will inherit the job. +func postStartCommand(cmd *exec.Cmd, processGroup bool) error { + if !processGroup || cmd.Process == nil { + return nil + } + + pid := cmd.Process.Pid + + // Open the process with the rights needed for job assignment, termination, + // and resuming (PROCESS_SUSPEND_RESUME). + procHandle, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_SUSPEND_RESUME|windows.SYNCHRONIZE, + false, uint32(pid)) + if err != nil { + cmd.Process.Kill() + return fmt.Errorf("OpenProcess: %w", err) + } + // procHandle is kept open for the lifetime of postStartCommand; the cleanup + // goroutine below takes ownership and closes it when the process exits. + + // Create a job object. + job, err := windows.CreateJobObject(nil, nil) + if err != nil { + windows.CloseHandle(procHandle) + cmd.Process.Kill() + return fmt.Errorf("CreateJobObject: %w", err) + } + + // Set the job to kill all processes when the last job handle is closed. + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} + info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + _, err = windows.SetInformationJobObject( + job, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + windows.CloseHandle(job) + windows.CloseHandle(procHandle) + cmd.Process.Kill() + return fmt.Errorf("SetInformationJobObject: %w", err) + } + + // Assign the process to the job before resuming it, so all child processes + // it spawns will also belong to the job. + if err = windows.AssignProcessToJobObject(job, procHandle); err != nil { + windows.CloseHandle(job) + windows.CloseHandle(procHandle) + cmd.Process.Kill() + return fmt.Errorf("AssignProcessToJobObject: %w", err) + } + + entry := &jobEntry{handle: job} + + // Store the job entry before resuming so interruptCommand/killCommand can find it. + jobHandles.Store(pid, entry) + + // Resume the suspended process. NtResumeProcess accepts a process handle + // directly, unlike ResumeThread which requires a thread handle. + // NTSTATUS 0 == STATUS_SUCCESS. + // + // We must pin procHandle as a uintptr passed to a syscall only during the + // call itself; storing it before and using it here is correct because + // procHandle is a windows.Handle (uintptr) that we own. + if status, _, _ := ntResumeProcess.Call(uintptr(procHandle)); status != 0 { + jobHandles.Delete(pid) + entry.terminate(1) + windows.CloseHandle(procHandle) + return fmt.Errorf("NtResumeProcess: NTSTATUS 0x%x", status) + } + + // Release the job handle when the process exits naturally. + // procHandle was opened with SYNCHRONIZE so we can wait on it directly, + // avoiding a second OpenProcess call. + go func() { + defer windows.CloseHandle(procHandle) + windows.WaitForSingleObject(procHandle, windows.INFINITE) + if e, ok := jobHandles.LoadAndDelete(pid); ok { + e.(*jobEntry).terminate(0) + } + }() + return nil +} + +// interruptCommand sends CTRL_BREAK_EVENT to the process group if processGroup +// is true, otherwise signals the individual process. +// +// Note: processes created with CREATE_NEW_PROCESS_GROUP ignore CTRL_C_EVENT, +// so CTRL_BREAK_EVENT must be used instead. If the console event fails, +// the job object is terminated as a fallback. +func interruptCommand(cmd *exec.Cmd, processGroup bool) error { + if processGroup { + err := windows.GenerateConsoleCtrlEvent(windows.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid)) + if err == nil { + return nil + } + // Fall back to terminating the job object if the console event fails. + if e, ok := jobHandles.LoadAndDelete(cmd.Process.Pid); ok { + return e.(*jobEntry).terminate(1) + } + } + return cmd.Process.Signal(os.Interrupt) +} + +// killCommand terminates the job object if processGroup is true, +// otherwise kills the individual process. +func killCommand(cmd *exec.Cmd, processGroup bool) error { + if processGroup { + if e, ok := jobHandles.LoadAndDelete(cmd.Process.Pid); ok { + return e.(*jobEntry).terminate(1) + } + } + return cmd.Process.Kill() +}