Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions interp/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 20 additions & 14 deletions interp/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion interp/os_notunix.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
// Copyright (c) 2017, Andrey Nering <andrey.nering@gmail.com>
// See LICENSE for licensing information

//go:build !unix
//go:build !unix && !windows

package interp

import (
"context"
"fmt"
"os/exec"

"mvdan.cc/sh/v3/syntax"
)
Expand Down Expand Up @@ -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()
}
28 changes: 28 additions & 0 deletions interp/os_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package interp

import (
"context"
"os/exec"
"os/user"
"strconv"
"syscall"
Expand Down Expand Up @@ -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()
}
229 changes: 229 additions & 0 deletions interp/os_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) 2017, Andrey Nering <andrey.nering@gmail.com>
// 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()
}