From d44997d4d579b242d4783f75760f9d53418b1b25 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sun, 30 Aug 2026 15:30:17 +0400 Subject: [PATCH 1/5] os: start processes with posix_spawn Use posix_spawn on hosted Linux and Darwin. Map process files, apply the working directory and process group, and clear the child signal mask. Use wait4 for process status and support Kill and Signal. Mark Darwin pipes close-on-exec under ForkLock. Darwin also needs the fcntl wrapper in PR #5612 and the libSystem symbols in PR #5636. Concurrent spawn and pipe creation need the RWMutex fix in PR #5630. Keep the process stubs on other targets and add process regression tests. --- src/os/exec.go | 49 +--- src/os/exec_linux.go | 103 -------- src/os/exec_linux_test.go | 78 ------ src/os/exec_other.go | 45 +++- src/os/exec_posix_spawn.go | 408 ++++++++++++++++++++++++++++++ src/os/exec_posix_spawn_darwin.go | 22 ++ src/os/exec_posix_spawn_linux.go | 66 +++++ src/os/exec_spawn_test.go | 322 +++++++++++++++++++++++ src/os/file_darwin.go | 11 +- src/os/osexec.go | 58 ----- 10 files changed, 879 insertions(+), 283 deletions(-) delete mode 100644 src/os/exec_linux.go delete mode 100644 src/os/exec_linux_test.go create mode 100644 src/os/exec_posix_spawn.go create mode 100644 src/os/exec_posix_spawn_darwin.go create mode 100644 src/os/exec_posix_spawn_linux.go create mode 100644 src/os/exec_spawn_test.go delete mode 100644 src/os/osexec.go diff --git a/src/os/exec.go b/src/os/exec.go index 28406f916b..cc00a10e0e 100644 --- a/src/os/exec.go +++ b/src/os/exec.go @@ -5,6 +5,9 @@ import ( "syscall" ) +// Errors StartProcess returns for a ProcAttr that it cannot honour. On a +// hosted OS only ErrNotImplementedSys is reachable. The other two stay because +// they are part of the exported API of this package. var ( ErrNotImplementedDir = errors.New("directory setting not implemented") ErrNotImplementedSys = errors.New("sys setting not implemented") @@ -36,35 +39,12 @@ type ProcAttr struct { // ErrProcessDone indicates a Process has finished. var ErrProcessDone = errors.New("os: process already finished") -type ProcessState struct { -} - -func (p *ProcessState) String() string { - return "" // TODO -} -func (p *ProcessState) Success() bool { - return false // TODO -} - -// Sys returns system-dependent exit information about -// the process. Convert it to the appropriate underlying -// type, such as syscall.WaitStatus on Unix, to access its contents. -func (p *ProcessState) Sys() interface{} { - return nil // TODO -} - -func (p *ProcessState) Exited() bool { - return false // TODO -} - -// ExitCode returns the exit code of the exited process, or -1 -// if the process hasn't exited or was terminated by a signal. -func (p *ProcessState) ExitCode() int { - return -1 // TODO -} - type Process struct { Pid int + + // done reports whether Wait reaped this process. A signal to a reaped pid + // is unsafe, because the number can belong to an unrelated process. + done int32 } // StartProcess starts a new process with the program, arguments and attributes specified by name, argv and attr. @@ -73,21 +53,6 @@ func StartProcess(name string, argv []string, attr *ProcAttr) (*Process, error) return startProcess(name, argv, attr) } -func (p *Process) Wait() (*ProcessState, error) { - if p.Pid == -1 { - return nil, syscall.EINVAL - } - return nil, ErrNotImplemented -} - -func (p *Process) Kill() error { - return ErrNotImplemented -} - -func (p *Process) Signal(sig Signal) error { - return ErrNotImplemented -} - func Ignore(sig ...Signal) { // leave all the signals unaltered return diff --git a/src/os/exec_linux.go b/src/os/exec_linux.go deleted file mode 100644 index 6914a2c285..0000000000 --- a/src/os/exec_linux.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2009 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build linux && !baremetal && !tinygo.wasm && !nintendoswitch - -package os - -import ( - "errors" - "runtime" - "syscall" -) - -// The only signal values guaranteed to be present in the os package on all -// systems are os.Interrupt (send the process an interrupt) and os.Kill (force -// the process to exit). On Windows, sending os.Interrupt to a process with -// os.Process.Signal is not implemented; it will return an error instead of -// sending a signal. -var ( - Interrupt Signal = syscall.SIGINT - Kill Signal = syscall.SIGKILL -) - -// Keep compatible with golang and always succeed and return new proc with pid on Linux. -func findProcess(pid int) (*Process, error) { - return &Process{Pid: pid}, nil -} - -func (p *Process) release() error { - // NOOP for unix. - p.Pid = -1 - // no need for a finalizer anymore - runtime.SetFinalizer(p, nil) - return nil -} - -// This function is a wrapper around the forkExec function, which is a wrapper around the fork and execve system calls. -// The StartProcess function creates a new process by forking the current process and then calling execve to replace the current process with the new process. -// It thereby replaces the newly created process with the specified command and arguments. -// Differences to upstream golang implementation (https://cs.opensource.google/go/go/+/master:src/syscall/exec_unix.go;l=143): -// * No setting of Process Attributes -// * Ignoring Ctty -// * No ForkLocking (might be introduced by #4273) -// * No parent-child communication via pipes (TODO) -// * No waiting for crashes child processes to prohibit zombie process accumulation / Wait status checking (TODO) -func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) { - if argv == nil { - return 0, errors.New("exec: no argv") - } - - if len(argv) == 0 { - return 0, errors.New("exec: no argv") - } - - if attr == nil { - attr = new(ProcAttr) - } - - p, err := fork() - pid = int(p) - - if err != nil { - return 0, err - } - - // else code runs in child, which then should exec the new process - err = execve(argv0, argv, attr.Env) - if err != nil { - // exec failed - return 0, err - } - // 3. TODO: use pipes to communicate back child status - return pid, nil -} - -// In Golang, the idiomatic way to create a new process is to use the StartProcess function. -// Since the Model of operating system processes in tinygo differs from the one in Golang, we need to implement the StartProcess function differently. -// The startProcess function is a wrapper around the forkExec function, which is a wrapper around the fork and execve system calls. -// The StartProcess function creates a new process by forking the current process and then calling execve to replace the current process with the new process. -// It thereby replaces the newly created process with the specified command and arguments. -func startProcess(name string, argv []string, attr *ProcAttr) (p *Process, err error) { - if attr != nil { - if attr.Dir != "" { - return nil, ErrNotImplementedDir - } - - if attr.Sys != nil { - return nil, ErrNotImplementedSys - } - - if len(attr.Files) != 0 { - return nil, ErrNotImplementedFiles - } - } - - pid, err := forkExec(name, argv, attr) - if err != nil { - return nil, err - } - - return findProcess(pid) -} diff --git a/src/os/exec_linux_test.go b/src/os/exec_linux_test.go deleted file mode 100644 index 34f1fef983..0000000000 --- a/src/os/exec_linux_test.go +++ /dev/null @@ -1,78 +0,0 @@ -//go:build linux && !baremetal && !tinygo.wasm - -package os_test - -import ( - "errors" - . "os" - "runtime" - "syscall" - "testing" -) - -// Test the functionality of the forkExec function, which is used to fork and exec a new process. -// This test is not run on Windows, as forkExec is not supported on Windows. -// This test is not run on Plan 9, as forkExec is not supported on Plan 9. -func TestForkExec(t *testing.T) { - if runtime.GOOS != "linux" { - t.Logf("skipping test on %s", runtime.GOOS) - return - } - - proc, err := StartProcess("/bin/echo", []string{"hello", "world"}, &ProcAttr{}) - if !errors.Is(err, nil) { - t.Fatalf("forkExec failed: %v", err) - } - - if proc == nil { - t.Fatalf("proc is nil") - } - - if proc.Pid == 0 { - t.Fatalf("forkExec failed: new process has pid 0") - } -} - -func TestForkExecErrNotExist(t *testing.T) { - proc, err := StartProcess("invalid", []string{"invalid"}, &ProcAttr{}) - if !errors.Is(err, ErrNotExist) { - t.Fatalf("wanted ErrNotExist, got %s\n", err) - } - - if proc != nil { - t.Fatalf("wanted nil, got %v\n", proc) - } -} - -func TestForkExecProcDir(t *testing.T) { - proc, err := StartProcess("/bin/echo", []string{"hello", "world"}, &ProcAttr{Dir: "dir"}) - if !errors.Is(err, ErrNotImplementedDir) { - t.Fatalf("wanted ErrNotImplementedDir, got %v\n", err) - } - - if proc != nil { - t.Fatalf("wanted nil, got %v\n", proc) - } -} - -func TestForkExecProcSys(t *testing.T) { - proc, err := StartProcess("/bin/echo", []string{"hello", "world"}, &ProcAttr{Sys: &syscall.SysProcAttr{}}) - if !errors.Is(err, ErrNotImplementedSys) { - t.Fatalf("wanted ErrNotImplementedSys, got %v\n", err) - } - - if proc != nil { - t.Fatalf("wanted nil, got %v\n", proc) - } -} - -func TestForkExecProcFiles(t *testing.T) { - proc, err := StartProcess("/bin/echo", []string{"hello", "world"}, &ProcAttr{Files: []*File{}}) - if !errors.Is(err, ErrNotImplementedFiles) { - t.Fatalf("wanted ErrNotImplementedFiles, got %v\n", err) - } - - if proc != nil { - t.Fatalf("wanted nil, got %v\n", proc) - } -} diff --git a/src/os/exec_other.go b/src/os/exec_other.go index b05e2830db..05fcc39988 100644 --- a/src/os/exec_other.go +++ b/src/os/exec_other.go @@ -1,4 +1,4 @@ -//go:build (!aix && !android && !freebsd && !linux && !netbsd && !openbsd && !plan9 && !solaris) || baremetal || tinygo.wasm || nintendoswitch +//go:build (!aix && !android && !darwin && !freebsd && !linux && !netbsd && !openbsd && !plan9 && !solaris) || baremetal || tinygo.wasm || nintendoswitch package os @@ -18,6 +18,49 @@ func (p *Process) release() error { return nil } +// ProcessState is a placeholder on targets that have no process model. +type ProcessState struct { +} + +func (p *ProcessState) String() string { + return "" // TODO +} +func (p *ProcessState) Success() bool { + return false // TODO +} + +// Sys returns system-dependent exit information about +// the process. Convert it to the appropriate underlying +// type, such as syscall.WaitStatus on Unix, to access its contents. +func (p *ProcessState) Sys() interface{} { + return nil // TODO +} + +func (p *ProcessState) Exited() bool { + return false // TODO +} + +// ExitCode returns the exit code of the exited process, or -1 +// if the process hasn't exited or was terminated by a signal. +func (p *ProcessState) ExitCode() int { + return -1 // TODO +} + +func (p *Process) Wait() (*ProcessState, error) { + if p.Pid == -1 { + return nil, syscall.EINVAL + } + return nil, ErrNotImplemented +} + +func (p *Process) Kill() error { + return ErrNotImplemented +} + +func (p *Process) Signal(sig Signal) error { + return ErrNotImplemented +} + func forkExec(_ string, _ []string, _ *ProcAttr) (pid int, err error) { return 0, ErrNotImplemented } diff --git a/src/os/exec_posix_spawn.go b/src/os/exec_posix_spawn.go new file mode 100644 index 0000000000..f5a51617c9 --- /dev/null +++ b/src/os/exec_posix_spawn.go @@ -0,0 +1,408 @@ +// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build (linux || darwin) && !baremetal && !tinygo.wasm && !nintendoswitch + +package os + +import ( + "errors" + "internal/itoa" + "runtime" + "sync/atomic" + "syscall" + _ "unsafe" // for go:linkname +) + +// Process creation on a hosted OS uses posix_spawn(3) and not fork(2) plus +// execve(2). These targets run the threads scheduler and collect with Boehm, +// so a fork from Go gives the child one thread that holds the locks of the +// other threads, and the stop-the-world signal of the collector can arrive +// between the fork and the exec. posix_spawn does the clone and the exec +// inside libc, where no Go code runs. +// +// posix_spawn takes two POSIX objects whose shape is different for each OS, so +// the types are in exec_posix_spawn_linux.go and exec_posix_spawn_darwin.go. + +// The only signal values guaranteed to be present in the os package on all +// systems are os.Interrupt (send the process an interrupt) and os.Kill (force +// the process to exit). On Windows, sending os.Interrupt to a process with +// os.Process.Signal is not implemented; it will return an error instead of +// sending a signal. +var ( + Interrupt Signal = syscall.SIGINT + Kill Signal = syscall.SIGKILL +) + +// Give the child an empty signal mask. A blocked mask survives an exec, and +// the spawning thread can carry the signal of the collector blocked. +const _POSIX_SPAWN_SETSIGMASK = 0x08 + +// POSIX_SPAWN_SETPGROUP puts the child in the process group of +// posix_spawnattr_setpgroup. The value is 2 in lib/musl/include/spawn.h and in +// the of Darwin. +const _POSIX_SPAWN_SETPGROUP = 0x02 + +// Keep compatible with golang and always succeed and return new proc with pid. +func findProcess(pid int) (*Process, error) { + return &Process{Pid: pid}, nil +} + +func (p *Process) release() error { + // NOOP for unix. + p.Pid = -1 + // no need for a finalizer anymore + runtime.SetFinalizer(p, nil) + return nil +} + +// ProcessState stores information about a process, as reported by Wait. +type ProcessState struct { + pid int // The process's id. + status syscall.WaitStatus // System-dependent status info. + rusage *syscall.Rusage +} + +// Pid returns the process id of the exited process. +func (p *ProcessState) Pid() int { + return p.pid +} + +func (p *ProcessState) String() string { + if p == nil { + return "" + } + status := p.status + res := "" + switch { + case status.Exited(): + res = "exit status " + itoa.Itoa(status.ExitStatus()) + case status.Signaled(): + res = "signal: " + status.Signal().String() + case status.Stopped(): + res = "stop signal: " + status.StopSignal().String() + if status.StopSignal() == syscall.SIGTRAP && status.TrapCause() != 0 { + res += " (trap " + itoa.Itoa(status.TrapCause()) + ")" + } + case status.Continued(): + res = "continued" + } + if status.CoreDump() { + res += " (core dumped)" + } + return res +} + +func (p *ProcessState) Success() bool { + return p.status.ExitStatus() == 0 +} + +// Sys returns system-dependent exit information about +// the process. Convert it to the appropriate underlying +// type, such as syscall.WaitStatus on Unix, to access its contents. +func (p *ProcessState) Sys() interface{} { + return p.status +} + +// SysUsage returns system-dependent resource usage information about +// the exited process. Convert it to the appropriate underlying +// type, such as *syscall.Rusage on Unix, to access its contents. +func (p *ProcessState) SysUsage() interface{} { + return p.rusage +} + +func (p *ProcessState) Exited() bool { + return p.status.Exited() +} + +// ExitCode returns the exit code of the exited process, or -1 +// if the process hasn't exited or was terminated by a signal. +func (p *ProcessState) ExitCode() int { + // return -1 if the process hasn't started. + if p == nil || !p.status.Exited() { + return -1 + } + return p.status.ExitStatus() +} + +// Wait waits for the Process to exit, and then returns a ProcessState +// describing its status and an error, if any. +func (p *Process) Wait() (*ProcessState, error) { + if p.Pid == -1 { + return nil, syscall.EINVAL + } + var status syscall.WaitStatus + var rusage syscall.Rusage + var wpid int + var err error + for { + wpid, err = syscall.Wait4(p.Pid, &status, 0, &rusage) + // The collector stops the world with a signal, so a thread in wait4 + // gets EINTR as a matter of course. + if err != syscall.EINTR { + break + } + } + if err != nil { + return nil, NewSyscallError("wait", err) + } + atomic.StoreInt32(&p.done, 1) + return &ProcessState{pid: wpid, status: status, rusage: &rusage}, nil +} + +// Signal sends a signal to the Process. Sending Interrupt on Windows is not +// implemented. +func (p *Process) Signal(sig Signal) error { + if p.Pid == -1 { + return errors.New("os: process already released") + } + if p.Pid == 0 { + return errors.New("os: process not initialized") + } + if atomic.LoadInt32(&p.done) != 0 { + return ErrProcessDone + } + s, ok := sig.(syscall.Signal) + if !ok { + return errors.New("os: unsupported signal type") + } + if err := syscall.Kill(p.Pid, s); err != nil { + // Another goroutine can reap the process between the check above and + // the kill. exec.CommandContext expects ErrProcessDone here. + if err == syscall.ESRCH { + return ErrProcessDone + } + return err + } + return nil +} + +// Kill causes the Process to exit immediately. Kill does not wait until the +// Process has actually exited. This only kills the Process itself, not any +// other processes it may have started. +func (p *Process) Kill() error { + return p.Signal(Kill) +} + +// startProcess creates the child with posix_spawn instead of a fork and exec +// pair. +func startProcess(name string, argv []string, attr *ProcAttr) (p *Process, err error) { + if attr == nil { + attr = new(ProcAttr) + } + if attr.Sys != nil { + // Refuse by name every field that posix_spawn cannot express. Only + // Setpgid and Pgid are honoured. + if err := checkSysProcAttr(attr.Sys); err != nil { + return nil, err + } + } + + pid, err := forkExec(name, argv, attr) + if err != nil { + return nil, err + } + + return &Process{Pid: pid}, nil +} + +// forkExec spawns the program at argv0 and returns its pid. It does not fork. +// posix_spawn reports a failed exec as its return value, so no status pipe is +// necessary. +func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) { + if len(argv) == 0 { + return 0, errors.New("exec: no argv") + } + if attr == nil { + attr = new(ProcAttr) + } + + argv0p, err := syscall.BytePtrFromString(argv0) + if err != nil { + return 0, err + } + argvp, err := syscall.SlicePtrFromStrings(argv) + if err != nil { + return 0, err + } + env := attr.Env + if env == nil { + // A nil Env means the environment of the parent. + env = Environ() + } + envp, err := syscall.SlicePtrFromStrings(env) + if err != nil { + return 0, err + } + + var fa spawnFileActions + if errno := posix_spawn_file_actions_init(&fa); errno != 0 { + return 0, syscall.Errno(errno) + } + defer posix_spawn_file_actions_destroy(&fa) + + var sa spawnAttr + if errno := posix_spawnattr_init(&sa); errno != 0 { + return 0, syscall.Errno(errno) + } + defer posix_spawnattr_destroy(&sa) + + var mask sigset + if errno := posix_spawnattr_setsigmask(&sa, &mask); errno != 0 { + return 0, syscall.Errno(errno) + } + + flags := int16(_POSIX_SPAWN_SETSIGMASK) + + // Setpgid is the one SysProcAttr field that posix_spawn can express. A + // Pgid of 0 makes a new group whose id is the pid of the child. + if attr.Sys != nil && attr.Sys.Setpgid { + if errno := posix_spawnattr_setpgroup(&sa, int32(attr.Sys.Pgid)); errno != 0 { + return 0, syscall.Errno(errno) + } + flags |= _POSIX_SPAWN_SETPGROUP + } + + if errno := posix_spawnattr_setflags(&sa, flags); errno != 0 { + return 0, syscall.Errno(errno) + } + + if attr.Dir != "" { + dirp, err := syscall.BytePtrFromString(attr.Dir) + if err != nil { + return 0, err + } + // Darwin stores the pointer and not a copy of the path, and the + // collector cannot see it. Keep the Go bytes alive until the spawn. + defer runtime.KeepAlive(dirp) + if errno := posix_spawn_file_actions_addchdir_np(&fa, dirp); errno != 0 { + return 0, syscall.Errno(errno) + } + } + + // Entry i becomes descriptor i in the child, and a missing entry means + // that the descriptor is closed. + for i, f := range attr.Files { + fd := ^uintptr(0) + if f != nil { + fd = f.Fd() + } + if fd == ^uintptr(0) { + if errno := posix_spawn_file_actions_addclose(&fa, int32(i)); errno != 0 { + return 0, syscall.Errno(errno) + } + continue + } + // A dup2 onto the same descriptor clears FD_CLOEXEC and is not a + // no-op, which is what an inherited os.Stdin needs. + if errno := posix_spawn_file_actions_adddup2(&fa, int32(fd), int32(i)); errno != 0 { + return 0, syscall.Errno(errno) + } + } + + // Close the standard descriptors that ProcAttr.Files does not name, which + // is what syscall.forkAndExecInChild does in the standard library. + for i := len(attr.Files); i < 3; i++ { + if errno := posix_spawn_file_actions_addclose(&fa, int32(i)); errno != 0 { + return 0, syscall.Errno(errno) + } + } + + var childPid int32 + // ForkLock keeps a descriptor made without O_CLOEXEC out of a child that + // another goroutine spawns at the same time. + syscall.ForkLock.Lock() + errno := posix_spawn(&childPid, argv0p, &fa, &sa, &argvp[0], &envp[0]) + syscall.ForkLock.Unlock() + runtime.KeepAlive(argv0p) + runtime.KeepAlive(argvp) + runtime.KeepAlive(envp) + if errno != 0 { + return 0, syscall.Errno(errno) + } + + return int(childPid), nil +} + +// Bindings for the posix_spawn family. They use //go:linkname and not +// //export, because //export promises that pointer arguments do not escape, +// and the objects here outlive the call. + +//go:linkname posix_spawn posix_spawn +func posix_spawn(pid *int32, path *byte, fa *spawnFileActions, sa *spawnAttr, argv **byte, envp **byte) int32 + +//go:linkname posix_spawn_file_actions_init posix_spawn_file_actions_init +func posix_spawn_file_actions_init(fa *spawnFileActions) int32 + +//go:linkname posix_spawn_file_actions_destroy posix_spawn_file_actions_destroy +func posix_spawn_file_actions_destroy(fa *spawnFileActions) int32 + +//go:linkname posix_spawn_file_actions_adddup2 posix_spawn_file_actions_adddup2 +func posix_spawn_file_actions_adddup2(fa *spawnFileActions, fildes, newfildes int32) int32 + +//go:linkname posix_spawn_file_actions_addclose posix_spawn_file_actions_addclose +func posix_spawn_file_actions_addclose(fa *spawnFileActions, fildes int32) int32 + +// Present in musl since 1.1.24 and in macOS since 10.15. +// +//go:linkname posix_spawn_file_actions_addchdir_np posix_spawn_file_actions_addchdir_np +func posix_spawn_file_actions_addchdir_np(fa *spawnFileActions, path *byte) int32 + +//go:linkname posix_spawnattr_init posix_spawnattr_init +func posix_spawnattr_init(sa *spawnAttr) int32 + +//go:linkname posix_spawnattr_destroy posix_spawnattr_destroy +func posix_spawnattr_destroy(sa *spawnAttr) int32 + +//go:linkname posix_spawnattr_setflags posix_spawnattr_setflags +func posix_spawnattr_setflags(sa *spawnAttr, flags int16) int32 + +//go:linkname posix_spawnattr_setsigmask posix_spawnattr_setsigmask +func posix_spawnattr_setsigmask(sa *spawnAttr, mask *sigset) int32 + +//go:linkname posix_spawnattr_setpgroup posix_spawnattr_setpgroup +func posix_spawnattr_setpgroup(sa *spawnAttr, pgroup int32) int32 + +// unsupportedSysFieldError names the SysProcAttr field that this +// implementation cannot honour. It unwraps to ErrNotImplementedSys. +type unsupportedSysFieldError struct { + field string +} + +func (e *unsupportedSysFieldError) Error() string { + return "os: SysProcAttr." + e.field + ": " + ErrNotImplementedSys.Error() +} + +func (e *unsupportedSysFieldError) Unwrap() error { + return ErrNotImplementedSys +} + +func errUnsupportedSysField(field string) error { + return &unsupportedSysFieldError{field: field} +} + +// checkSysProcAttrCommon rejects every field that both Linux and Darwin +// declare and that posix_spawn cannot express. Setpgid and Pgid are absent, +// because forkExec honours them. +func checkSysProcAttrCommon(sys *syscall.SysProcAttr) error { + switch { + case sys.Chroot != "": + return errUnsupportedSysField("Chroot") + case sys.Credential != nil: + return errUnsupportedSysField("Credential") + case sys.Ptrace: + return errUnsupportedSysField("Ptrace") + case sys.Setsid: + return errUnsupportedSysField("Setsid") + case sys.Setctty: + return errUnsupportedSysField("Setctty") + case sys.Noctty: + return errUnsupportedSysField("Noctty") + case sys.Ctty != 0: + return errUnsupportedSysField("Ctty") + case sys.Foreground: + return errUnsupportedSysField("Foreground") + } + return nil +} diff --git a/src/os/exec_posix_spawn_darwin.go b/src/os/exec_posix_spawn_darwin.go new file mode 100644 index 0000000000..bbc6ac76b8 --- /dev/null +++ b/src/os/exec_posix_spawn_darwin.go @@ -0,0 +1,22 @@ +//go:build darwin + +package os + +import "syscall" + +// Darwin declares both POSIX objects as opaque pointers, so the +// object a caller allocates is one pointer wide and libc allocates the rest. +// The type is uintptr because libc stores memory there that is not Go memory. +type spawnFileActions uintptr + +type spawnAttr uintptr + +// The sigset_t of Darwin is a 32-bit mask. The zero value is the empty set. +type sigset uint32 + +// checkSysProcAttr reports whether the SysProcAttr asks for something that +// posix_spawn cannot do. Darwin declares only the common fields plus Setpgid +// and Pgid. +func checkSysProcAttr(sys *syscall.SysProcAttr) error { + return checkSysProcAttrCommon(sys) +} diff --git a/src/os/exec_posix_spawn_linux.go b/src/os/exec_posix_spawn_linux.go new file mode 100644 index 0000000000..0d2e1c5e8c --- /dev/null +++ b/src/os/exec_posix_spawn_linux.go @@ -0,0 +1,66 @@ +//go:build linux && !baremetal && !tinygo.wasm && !nintendoswitch + +package os + +import "syscall" + +// Storage for the two by-value POSIX objects posix_spawn takes. musl declares +// them in lib/musl/include/spawn.h as +// +// typedef struct { +// int __pad0[2]; +// void *__actions; +// int __pad[16]; +// } posix_spawn_file_actions_t; +// +// typedef struct { +// int __flags; +// pid_t __pgrp; +// sigset_t __def, __mask; +// int __prio, __pol; +// void *__fn; +// char __pad[64-sizeof(void *)]; +// } posix_spawnattr_t; +// +// with a sigset_t of 128 bytes. The file-actions object is then 80 bytes on +// LP64 and 76 bytes on a 32-bit target, and the attribute object is 336 bytes. +// The arrays below are larger than that and uint64 for the alignment. Only +// libc looks inside them. +type spawnFileActions [16]uint64 + +type spawnAttr [48]uint64 + +// The sigset_t of musl, 128 bytes. The zero value is the empty set. +type sigset [16]uint64 + +// checkSysProcAttr reports whether the SysProcAttr asks for something that +// posix_spawn cannot do. Linux declares more fields than POSIX, and each of +// them needs Go code to run in the child between the clone and the exec. +func checkSysProcAttr(sys *syscall.SysProcAttr) error { + if err := checkSysProcAttrCommon(sys); err != nil { + return err + } + switch { + case sys.Pdeathsig != 0: + return errUnsupportedSysField("Pdeathsig") + case sys.Cloneflags != 0: + return errUnsupportedSysField("Cloneflags") + case sys.Unshareflags != 0: + return errUnsupportedSysField("Unshareflags") + case sys.UidMappings != nil: + return errUnsupportedSysField("UidMappings") + case sys.GidMappings != nil: + return errUnsupportedSysField("GidMappings") + case sys.GidMappingsEnableSetgroups: + return errUnsupportedSysField("GidMappingsEnableSetgroups") + case sys.AmbientCaps != nil: + return errUnsupportedSysField("AmbientCaps") + case sys.UseCgroupFD: + return errUnsupportedSysField("UseCgroupFD") + case sys.CgroupFD != 0: + return errUnsupportedSysField("CgroupFD") + case sys.PidFD != nil: + return errUnsupportedSysField("PidFD") + } + return nil +} diff --git a/src/os/exec_spawn_test.go b/src/os/exec_spawn_test.go new file mode 100644 index 0000000000..10e8efd993 --- /dev/null +++ b/src/os/exec_spawn_test.go @@ -0,0 +1,322 @@ +//go:build (linux || darwin) && !baremetal && !tinygo.wasm + +package os_test + +import ( + "errors" + . "os" + "strconv" + "strings" + "syscall" + "testing" +) + +// StartProcess spawns a new process and Wait reports its exit status. An +// empty ProcAttr.Files gives the child no standard descriptors, so the command +// here does not use any. +func TestForkExec(t *testing.T) { + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "exit 0"}, &ProcAttr{}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + + if proc == nil { + t.Fatalf("proc is nil") + } + + if proc.Pid == 0 { + t.Fatalf("StartProcess failed: new process has pid 0") + } + + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if !state.Exited() { + t.Errorf("wanted the process to have exited, got %v", state) + } + if !state.Success() { + t.Errorf("wanted a successful exit, got %v", state) + } + if state.ExitCode() != 0 { + t.Errorf("wanted exit code 0, got %d", state.ExitCode()) + } + if _, ok := state.Sys().(syscall.WaitStatus); !ok { + t.Errorf("wanted Sys() to be a syscall.WaitStatus, got %T", state.Sys()) + } +} + +// A process that exits non-zero must report that status rather than an error. +func TestForkExecExitStatus(t *testing.T) { + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "exit 3"}, &ProcAttr{}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if state.Success() { + t.Errorf("wanted an unsuccessful exit, got %v", state) + } + if state.ExitCode() != 3 { + t.Errorf("wanted exit code 3, got %d", state.ExitCode()) + } + if state.String() != "exit status 3" { + t.Errorf("wanted %q, got %q", "exit status 3", state.String()) + } +} + +// Killing a process must be reported as a signalled, not an exited, status. +func TestForkExecKill(t *testing.T) { + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "sleep 30"}, &ProcAttr{}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + + if err := proc.Kill(); err != nil { + t.Fatalf("Kill failed: %v", err) + } + + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if state.Exited() { + t.Errorf("wanted the process to have been signalled, got %v", state) + } + + // After the reap, a second signal must report that the process is done and + // must not reach an unrelated process with the same pid. + if err := proc.Kill(); !errors.Is(err, ErrProcessDone) { + t.Errorf("wanted ErrProcessDone, got %v", err) + } +} + +func TestForkExecErrNotExist(t *testing.T) { + proc, err := StartProcess("invalid", []string{"invalid"}, &ProcAttr{}) + if !errors.Is(err, ErrNotExist) { + t.Fatalf("wanted ErrNotExist, got %s\n", err) + } + + if proc != nil { + t.Fatalf("wanted nil, got %v\n", proc) + } +} + +// Dir is honoured through a chdir file action. +func TestForkExecProcDir(t *testing.T) { + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "test \"$(pwd -P)\" = /"}, &ProcAttr{Dir: "/"}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if !state.Success() { + t.Errorf("the child did not start in /, got %v", state) + } +} + +// A SysProcAttr with only zero fields asks for nothing, so it is accepted. +func TestForkExecProcSysEmpty(t *testing.T) { + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "exit 0"}, &ProcAttr{Sys: &syscall.SysProcAttr{}}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if !state.Success() { + t.Errorf("wanted a successful exit, got %v", state) + } +} + +// Every field that posix_spawn cannot express is refused by name, and the +// error unwraps to ErrNotImplementedSys. +func TestForkExecProcSysUnsupported(t *testing.T) { + for _, test := range []struct { + field string + sys *syscall.SysProcAttr + }{ + {"Chroot", &syscall.SysProcAttr{Chroot: "/"}}, + {"Ptrace", &syscall.SysProcAttr{Ptrace: true}}, + {"Setsid", &syscall.SysProcAttr{Setsid: true}}, + {"Setctty", &syscall.SysProcAttr{Setctty: true}}, + {"Noctty", &syscall.SysProcAttr{Noctty: true}}, + {"Ctty", &syscall.SysProcAttr{Ctty: 1}}, + {"Foreground", &syscall.SysProcAttr{Foreground: true}}, + } { + proc, err := StartProcess("/bin/echo", []string{"echo", "hello"}, &ProcAttr{Sys: test.sys}) + if !errors.Is(err, ErrNotImplementedSys) { + t.Errorf("%s: wanted an error wrapping ErrNotImplementedSys, got %v", test.field, err) + } + if err != nil && !strings.Contains(err.Error(), test.field) { + t.Errorf("%s: wanted the error to name the field, got %q", test.field, err.Error()) + } + if proc != nil { + t.Errorf("%s: wanted nil, got %v", test.field, proc) + } + } +} + +// startSleeper spawns a process that stays alive long enough for a check. +func startSleeper(t *testing.T, sys *syscall.SysProcAttr) *Process { + t.Helper() + proc, err := StartProcess("/bin/sleep", []string{"sleep", "30"}, &ProcAttr{Sys: sys}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + return proc +} + +// Setpgid with a Pgid of zero puts the child in a new process group whose id +// is the pid of the child. +func TestForkExecSetpgid(t *testing.T) { + proc := startSleeper(t, &syscall.SysProcAttr{Setpgid: true}) + defer func() { + proc.Kill() + proc.Wait() + }() + + pgid, err := syscall.Getpgid(proc.Pid) + if err != nil { + t.Fatalf("Getpgid(%d) failed: %v", proc.Pid, err) + } + if pgid != proc.Pid { + t.Errorf("wanted the child to lead its own group %d, got group %d", proc.Pid, pgid) + } + if pgid == Getpid() { + t.Errorf("the child stayed in the parent's group %d", pgid) + } +} + +// A non-zero Pgid joins an existing group instead of creating one. +func TestForkExecSetpgidJoin(t *testing.T) { + leader := startSleeper(t, &syscall.SysProcAttr{Setpgid: true}) + defer func() { + leader.Kill() + leader.Wait() + }() + + joiner := startSleeper(t, &syscall.SysProcAttr{Setpgid: true, Pgid: leader.Pid}) + defer func() { + joiner.Kill() + joiner.Wait() + }() + + pgid, err := syscall.Getpgid(joiner.Pid) + if err != nil { + t.Fatalf("Getpgid(%d) failed: %v", joiner.Pid, err) + } + if pgid != leader.Pid { + t.Errorf("wanted the second child in group %d, got group %d", leader.Pid, pgid) + } +} + +// Without Setpgid the child stays in the group that it inherited. +func TestForkExecInheritsProcessGroup(t *testing.T) { + proc := startSleeper(t, nil) + defer func() { + proc.Kill() + proc.Wait() + }() + + pgid, err := syscall.Getpgid(proc.Pid) + if err != nil { + t.Fatalf("Getpgid(%d) failed: %v", proc.Pid, err) + } + parent, err := syscall.Getpgid(Getpid()) + if err != nil { + t.Fatalf("Getpgid(self) failed: %v", err) + } + if pgid != parent { + t.Errorf("wanted the child in the parent's group %d, got group %d", parent, pgid) + } +} + +// A descriptor that the parent did not give to the child must not survive the +// exec. A child that holds a copy of the write end of a pipe keeps that pipe +// from a report of EOF. +func TestForkExecDescriptorsDoNotLeak(t *testing.T) { + r, w, err := Pipe() + if err != nil { + t.Fatalf("Pipe failed: %v", err) + } + defer r.Close() + defer w.Close() + + // The child gets no descriptors, so a successful write to the write end + // shows that the descriptor leaked. + script := "echo leaked >&" + strconv.Itoa(int(w.Fd())) + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", script}, &ProcAttr{ + Files: []*File{nil, nil, nil}, + }) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if state.Success() { + t.Errorf("the child inherited the parent's pipe write end (fd %d)", w.Fd()) + } +} + +// A standard descriptor that ProcAttr.Files does not name is closed in the +// child, which is what syscall.forkAndExecInChild does in the standard +// library. +func TestForkExecClosesUnnamedStdio(t *testing.T) { + // A dup of a closed descriptor is a redirection error in the special + // built-in exec, which makes a non-interactive shell exit. See POSIX + // 2.8.1, Consequences of Shell Errors. + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "exec 3>&1"}, &ProcAttr{}) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + state, err := proc.Wait() + if err != nil { + t.Fatalf("Wait failed: %v", err) + } + if state.Success() { + t.Errorf("the child still had a standard output, got %v", state) + } +} + +// Files are handed to the child as its descriptors 0, 1 and 2. +func TestForkExecProcFiles(t *testing.T) { + r, w, err := Pipe() + if err != nil { + t.Fatalf("Pipe failed: %v", err) + } + defer r.Close() + + proc, err := StartProcess("/bin/echo", []string{"echo", "piped"}, &ProcAttr{ + Files: []*File{nil, w, nil}, + }) + if err != nil { + w.Close() + t.Fatalf("StartProcess failed: %v", err) + } + // Close the copy of the write end in the parent, or the read below does + // not see the end of the file. + w.Close() + + buf := make([]byte, 32) + n, err := r.Read(buf) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if got := string(buf[:n]); got != "piped\n" { + t.Errorf("wanted %q, got %q", "piped\n", got) + } + + if _, err := proc.Wait(); err != nil { + t.Fatalf("Wait failed: %v", err) + } +} diff --git a/src/os/file_darwin.go b/src/os/file_darwin.go index 8d96b7296e..aa0af92753 100644 --- a/src/os/file_darwin.go +++ b/src/os/file_darwin.go @@ -3,5 +3,14 @@ package os import "syscall" func pipe(p []int) error { - return syscall.Pipe(p) + // Darwin has no pipe2, so mark the descriptors close-on-exec afterwards. + // ForkLock keeps a spawn out of the window between the two steps. + syscall.ForkLock.RLock() + defer syscall.ForkLock.RUnlock() + if err := syscall.Pipe(p); err != nil { + return err + } + syscall.CloseOnExec(p[0]) + syscall.CloseOnExec(p[1]) + return nil } diff --git a/src/os/osexec.go b/src/os/osexec.go deleted file mode 100644 index 6b2562a685..0000000000 --- a/src/os/osexec.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build linux && !baremetal && !tinygo.wasm && !nintendoswitch - -package os - -import ( - "syscall" - "unsafe" -) - -func fork() (pid int32, err error) { - pid = libc_fork() - if pid != 0 { - if errno := *libc_errno(); errno != 0 { - err = syscall.Errno(*libc_errno()) - } - } - return -} - -// the golang standard library does not expose interfaces for execve and fork, so we define them here the same way via the libc wrapper -func execve(pathname string, argv []string, envv []string) error { - argv0 := cstring(pathname) - - // transform argv and envv into the format expected by execve - argv1 := make([]*byte, len(argv)+1) - for i, arg := range argv { - argv1[i] = &cstring(arg)[0] - } - argv1[len(argv)] = nil - - env1 := make([]*byte, len(envv)+1) - for i, env := range envv { - env1[i] = &cstring(env)[0] - } - env1[len(envv)] = nil - - ret, _, err := syscall.Syscall(syscall.SYS_EXECVE, uintptr(unsafe.Pointer(unsafe.SliceData(argv0))), uintptr(unsafe.Pointer(unsafe.SliceData(argv1))), uintptr(unsafe.Pointer(unsafe.SliceData(env1)))) - if int(ret) != 0 { - return err - } - - return nil -} - -func cstring(s string) []byte { - data := make([]byte, len(s)+1) - copy(data, s) - // final byte should be zero from the initial allocation - return data -} - -//export fork -func libc_fork() int32 - -// Internal musl function to get the C errno pointer. -// -//export __errno_location -func libc_errno() *int32 From a993b16f58bd480b9fd8d42b8ddbe7228f19e781 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sat, 5 Sep 2026 06:25:51 +0400 Subject: [PATCH 2/5] os: preserve sources during process fd remapping --- src/os/exec_posix_spawn.go | 44 ++++++++++++- src/os/exec_remap_test.go | 124 +++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 src/os/exec_remap_test.go diff --git a/src/os/exec_posix_spawn.go b/src/os/exec_posix_spawn.go index f5a51617c9..437eadc9fa 100644 --- a/src/os/exec_posix_spawn.go +++ b/src/os/exec_posix_spawn.go @@ -281,14 +281,47 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) } } - // Entry i becomes descriptor i in the child, and a missing entry means - // that the descriptor is closed. + defer runtime.KeepAlive(attr.Files) + fds := make([]int, len(attr.Files)) + nextfd := len(fds) + if nextfd < 3 { + nextfd = 3 + } for i, f := range attr.Files { fd := ^uintptr(0) if f != nil { fd = f.Fd() } - if fd == ^uintptr(0) { + fds[i] = -1 + if fd != ^uintptr(0) { + if fd >= 1<<31-1 { + return 0, syscall.EBADF + } + fds[i] = int(fd) + if int(fd) >= nextfd { + nextfd = int(fd) + 1 + } + } + } + + // Save sources before an earlier action replaces or closes them. + // See Go src/syscall/exec_linux.go, forkAndExecInChild, Pass 1. + firstTemp := nextfd + for i, fd := range fds { + if fd >= 0 && fd < i { + if nextfd >= 1<<31-1 { + return 0, syscall.EINVAL + } + if errno := posix_spawn_file_actions_adddup2(&fa, int32(fd), int32(nextfd)); errno != 0 { + return 0, syscall.Errno(errno) + } + fds[i] = nextfd + nextfd++ + } + } + + for i, fd := range fds { + if fd == -1 { if errno := posix_spawn_file_actions_addclose(&fa, int32(i)); errno != 0 { return 0, syscall.Errno(errno) } @@ -308,6 +341,11 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) return 0, syscall.Errno(errno) } } + for fd := firstTemp; fd < nextfd; fd++ { + if errno := posix_spawn_file_actions_addclose(&fa, int32(fd)); errno != 0 { + return 0, syscall.Errno(errno) + } + } var childPid int32 // ForkLock keeps a descriptor made without O_CLOEXEC out of a child that diff --git a/src/os/exec_remap_test.go b/src/os/exec_remap_test.go new file mode 100644 index 0000000000..d6278bc303 --- /dev/null +++ b/src/os/exec_remap_test.go @@ -0,0 +1,124 @@ +//go:build (linux || darwin) && !baremetal && !tinygo.wasm && !nintendoswitch + +package os_test + +import ( + "errors" + "fmt" + . "os" + "syscall" + "testing" +) + +func TestForkExecFileRemapping(t *testing.T) { + for _, name := range []string{"cycle", "repeated", "closed-source", "identity", "sparse"} { + t.Run(name, func(t *testing.T) { + a, err := CreateTemp(t.TempDir(), "source-a") + if err != nil { + t.Fatal(err) + } + defer a.Close() + b, err := CreateTemp(t.TempDir(), "source-b") + if err != nil { + t.Fatal(err) + } + defer b.Close() + out, err := CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + defer out.Close() + for i, f := range []*File{a, b} { + if _, err := f.WriteString([]string{"A", "B"}[i]); err != nil { + t.Fatal(err) + } + if _, err := f.Seek(0, 0); err != nil { + t.Fatal(err) + } + } + x, y := int(a.Fd()), int(b.Fd()) + if x < 3 || y <= x { + t.Fatalf("unexpected source descriptors %d, %d", x, y) + } + files := make([]*File, y+3) + files[1], files[2] = out, Stderr + targets := []int{x, y} + switch name { + case "cycle": + files[x], files[y] = b, a + case "repeated": + files[x], files[y], files[y+1] = b, a, a + targets = []int{x, y, y + 1} + case "closed-source": + files[y] = a + targets = []int{y} + case "identity": + files[x], files[y] = a, b + case "sparse": + files[y+2] = a + targets = []int{y + 2} + } + argv := []string{"sh", "-c", "", "sh"} + script := "" + for i, fd := range targets { + script += fmt.Sprintf("test /dev/fd/%d -ef \"$%d\" && ", fd, i+1) + argv = append(argv, files[fd].Name()) + } + nextfd := len(files) + for _, f := range files { + if f != nil && int(f.Fd()) >= nextfd { + nextfd = int(f.Fd()) + 1 + } + } + for i, f := range files { + if f != nil && int(f.Fd()) < i { + script += fmt.Sprintf("test ! -e /dev/fd/%d && ", nextfd) + nextfd++ + } + } + argv[2] = script + "printf ok" + proc, err := StartProcess("/bin/sh", argv, &ProcAttr{Files: files}) + if err != nil { + t.Fatal(err) + } + state, err := proc.Wait() + if err != nil || !state.Success() { + t.Fatalf("Wait = %v, %v", state, err) + } + got, err := ReadFile(out.Name()) + if err != nil || string(got) != "ok" { + t.Fatalf("output = %q, %v, want ok", got, err) + } + for i, f := range []*File{a, b} { + got := make([]byte, 1) + _, err := f.ReadAt(got, 0) + if err != nil || string(got) != []string{"A", "B"}[i] { + t.Fatalf("parent source = %q, %v", got, err) + } + } + }) + } +} + +func TestForkExecInvalidRemapSource(t *testing.T) { + f, err := Open(DevNull) + if err != nil { + t.Fatal(err) + } + defer f.Close() + files := make([]*File, int(f.Fd())+2) + files[len(files)-1] = f + files[0] = NewFile(1<<31, "invalid") + proc, err := StartProcess("/bin/sh", []string{"sh", "-c", "exit 0"}, &ProcAttr{Files: files}) + if proc != nil { + proc.Kill() + proc.Wait() + t.Fatal("StartProcess accepted an invalid descriptor") + } + if !errors.Is(err, syscall.EBADF) { + t.Fatalf("StartProcess = %v, want EBADF", err) + } + if _, err := f.Stat(); err != nil { + t.Fatalf("parent source stat = %v", err) + } +} From 8af70341cc45fc6ccd9c8e5a8366def529b3c179 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sat, 5 Sep 2026 06:25:51 +0400 Subject: [PATCH 3/5] os: record the blocking pipe WaitDelay failure --- testdata/os-exec-waitdelay/README.md | 45 ++++++++++++++++++++++++++ testdata/os-exec-waitdelay/main.go | 48 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 testdata/os-exec-waitdelay/README.md create mode 100644 testdata/os-exec-waitdelay/main.go diff --git a/testdata/os-exec-waitdelay/README.md b/testdata/os-exec-waitdelay/README.md new file mode 100644 index 0000000000..f1c7cecb36 --- /dev/null +++ b/testdata/os-exec-waitdelay/README.md @@ -0,0 +1,45 @@ +# WaitDelay with a background child + +This manual test uses the Go standard library `os/exec` with TinyGo's `os`. +The shell exits and a background `sleep` keeps the output pipe open for two +seconds. `WaitDelay` is 100 ms. The test returns status 1 if the wait takes +one second or more, or if the error is not `exec.ErrWaitDelay`. + +Build and run from the repository root with the process changes in TINYGOROOT. + +```sh +tinygo build -p 1 -o /tmp/waitdelay ./testdata/os-exec-waitdelay/main.go +timeout 10 /tmp/waitdelay +timeout 10 /tmp/waitdelay pipe +go build -p 1 -o /tmp/waitdelay-go ./testdata/os-exec-waitdelay/main.go +timeout 10 /tmp/waitdelay-go +timeout 10 /tmp/waitdelay-go pipe +``` + +`timeout` is an external limit for Linux. The background child exits after +two seconds. The `pipe` mode closes a reader during a read. It closes the +writer two seconds later so that the test can finish if the read stays blocked. + +Measured on Linux arm64 with the released TinyGo 0.42.0 compiler, Go 1.27.0, +and a TINYGOROOT copy with PR #5634 and the fd remapping fix. + +| Test | TinyGo | Go | +| --- | --- | --- | +| WaitDelay 100 ms | 2.004 s, ErrWaitDelay | 101 ms, ErrWaitDelay | +| Read after reader close | 2.003 s, EOF | 57 us, file already closed | + +`src/os/file_anyos.go` calls `syscall.Read` and `syscall.Close` directly. +Closing the reader does not stop the active blocking read in this test. +Go's `Cmd.awaitGoroutines` closes the pipes when the timer expires, then waits +for the copy goroutines. That wait lasts until the background child closes +the pipe. The timer fires, but it cannot enforce the limit. + +This remains open. A fix needs interruptible pipe I/O and coordination between +close and active I/O. It must also prevent an old operation from using a reused +descriptor. Changes to spawn file actions or Darwin fcntl do not fix this Linux +failure. PR #5630 addresses lock contention, not this blocked read. + +The follow-up must test WaitDelay after normal exit and context cancellation, +blocked pipe reads and writes, prompt close, and descriptor reuse on hosted +Linux and Darwin. A process that keeps its inherited output open must not +keep `Cmd.Wait` blocked after the configured limit. diff --git a/testdata/os-exec-waitdelay/main.go b/testdata/os-exec-waitdelay/main.go new file mode 100644 index 0000000000..9e5d81bc4b --- /dev/null +++ b/testdata/os-exec-waitdelay/main.go @@ -0,0 +1,48 @@ +//go:build linux || darwin + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "time" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "pipe" { + r, w, err := os.Pipe() + if err != nil { + panic(err) + } + done := make(chan error, 1) + go func() { + var buf [1]byte + _, err := r.Read(buf[:]) + done <- err + }() + time.Sleep(100 * time.Millisecond) + go func() { + time.Sleep(2 * time.Second) + w.Close() + }() + start := time.Now() + closeErr := r.Close() + err = <-done + fmt.Printf("pipe close=%v read=%v elapsed=%v\n", closeErr, err, time.Since(start)) + return + } + cmd := exec.Command("/bin/sh", "-c", "sleep 2 &") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + cmd.WaitDelay = 100 * time.Millisecond + start := time.Now() + err := cmd.Run() + elapsed := time.Since(start) + fmt.Printf("WaitDelay=%v elapsed=%v error=%v\n", cmd.WaitDelay, elapsed, err) + if !errors.Is(err, exec.ErrWaitDelay) || elapsed >= time.Second { + os.Exit(1) + } +} From 84032b325c9343065746e367119b92ff6bab0600 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sat, 5 Sep 2026 06:49:59 +0400 Subject: [PATCH 4/5] os: make Darwin spawn close actions valid for unopened files Open /dev/null in the child before closing a nil or unnamed descriptor. Save remap sources before these actions. This avoids a parent descriptor check and its race with concurrent open. Use descriptor reads and exec redirection in remap tests. Check nil slots and temporary descriptors. The tests pass with ordinary Go on Darwin and Linux. Both TinyGo os suites pass with released compiler source overlays. Darwin also needs the addopen symbol from PR #5636. --- src/os/exec_posix_spawn.go | 8 ++--- src/os/exec_posix_spawn_darwin.go | 22 ++++++++++++- src/os/exec_posix_spawn_linux.go | 7 ++++ src/os/exec_remap_test.go | 54 +++++++++++++++++++------------ 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/os/exec_posix_spawn.go b/src/os/exec_posix_spawn.go index 437eadc9fa..485f9a585f 100644 --- a/src/os/exec_posix_spawn.go +++ b/src/os/exec_posix_spawn.go @@ -322,8 +322,8 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) for i, fd := range fds { if fd == -1 { - if errno := posix_spawn_file_actions_addclose(&fa, int32(i)); errno != 0 { - return 0, syscall.Errno(errno) + if err := addSpawnClose(&fa, int32(i)); err != nil { + return 0, err } continue } @@ -337,8 +337,8 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error) // Close the standard descriptors that ProcAttr.Files does not name, which // is what syscall.forkAndExecInChild does in the standard library. for i := len(attr.Files); i < 3; i++ { - if errno := posix_spawn_file_actions_addclose(&fa, int32(i)); errno != 0 { - return 0, syscall.Errno(errno) + if err := addSpawnClose(&fa, int32(i)); err != nil { + return 0, err } } for fd := firstTemp; fd < nextfd; fd++ { diff --git a/src/os/exec_posix_spawn_darwin.go b/src/os/exec_posix_spawn_darwin.go index bbc6ac76b8..1e73753436 100644 --- a/src/os/exec_posix_spawn_darwin.go +++ b/src/os/exec_posix_spawn_darwin.go @@ -2,7 +2,27 @@ package os -import "syscall" +import ( + "syscall" + _ "unsafe" +) + +var spawnDevNull = [...]byte{'/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l', 0} + +func addSpawnClose(fa *spawnFileActions, fd int32) error { + // Darwin rejects close actions on unopened descriptors. + // See posix_spawn(2), ERRORS, EBADF. + if errno := posix_spawn_file_actions_addopen(fa, fd, &spawnDevNull[0], syscall.O_RDONLY, 0); errno != 0 { + return syscall.Errno(errno) + } + if errno := posix_spawn_file_actions_addclose(fa, fd); errno != 0 { + return syscall.Errno(errno) + } + return nil +} + +//go:linkname posix_spawn_file_actions_addopen posix_spawn_file_actions_addopen +func posix_spawn_file_actions_addopen(fa *spawnFileActions, fd int32, path *byte, flags int32, mode uint16) int32 // Darwin declares both POSIX objects as opaque pointers, so the // object a caller allocates is one pointer wide and libc allocates the rest. diff --git a/src/os/exec_posix_spawn_linux.go b/src/os/exec_posix_spawn_linux.go index 0d2e1c5e8c..f5a0663d5f 100644 --- a/src/os/exec_posix_spawn_linux.go +++ b/src/os/exec_posix_spawn_linux.go @@ -4,6 +4,13 @@ package os import "syscall" +func addSpawnClose(fa *spawnFileActions, fd int32) error { + if errno := posix_spawn_file_actions_addclose(fa, fd); errno != 0 { + return syscall.Errno(errno) + } + return nil +} + // Storage for the two by-value POSIX objects posix_spawn takes. musl declares // them in lib/musl/include/spawn.h as // diff --git a/src/os/exec_remap_test.go b/src/os/exec_remap_test.go index d6278bc303..a96fafc2a1 100644 --- a/src/os/exec_remap_test.go +++ b/src/os/exec_remap_test.go @@ -11,6 +11,9 @@ import ( ) func TestForkExecFileRemapping(t *testing.T) { + if _, err := Stat("/bin/bash"); err != nil { + t.Skip("file remapping checks need /bin/bash for descriptors above 9") + } for _, name := range []string{"cycle", "repeated", "closed-source", "identity", "sparse"} { t.Run(name, func(t *testing.T) { a, err := CreateTemp(t.TempDir(), "source-a") @@ -29,7 +32,7 @@ func TestForkExecFileRemapping(t *testing.T) { } defer out.Close() for i, f := range []*File{a, b} { - if _, err := f.WriteString([]string{"A", "B"}[i]); err != nil { + if _, err := f.WriteString([]string{"A\n", "B\n"}[i]); err != nil { t.Fatal(err) } if _, err := f.Seek(0, 0); err != nil { @@ -58,11 +61,18 @@ func TestForkExecFileRemapping(t *testing.T) { files[y+2] = a targets = []int{y + 2} } - argv := []string{"sh", "-c", "", "sh"} - script := "" - for i, fd := range targets { - script += fmt.Sprintf("test /dev/fd/%d -ef \"$%d\" && ", fd, i+1) - argv = append(argv, files[fd].Name()) + for _, fd := range targets { + for _, f := range []*File{a, b} { + if _, err := f.Seek(0, 0); err != nil { + t.Fatal(err) + } + } + want := "A" + if files[fd] == b { + want = "B" + } + script := fmt.Sprintf("exec 0<&%d; IFS= read -r value; test \"$value\" = \"$1\"", fd) + checkRemapChild(t, files, script, want, true) } nextfd := len(files) for _, f := range files { @@ -71,24 +81,16 @@ func TestForkExecFileRemapping(t *testing.T) { } } for i, f := range files { + if f == nil && i >= 3 { + script := fmt.Sprintf("exec 2>/dev/null; exec 0<&%d", i) + checkRemapChild(t, files, script, "", false) + } if f != nil && int(f.Fd()) < i { - script += fmt.Sprintf("test ! -e /dev/fd/%d && ", nextfd) + script := fmt.Sprintf("exec 2>/dev/null; exec 0<&%d", nextfd) + checkRemapChild(t, files, script, "", false) nextfd++ } } - argv[2] = script + "printf ok" - proc, err := StartProcess("/bin/sh", argv, &ProcAttr{Files: files}) - if err != nil { - t.Fatal(err) - } - state, err := proc.Wait() - if err != nil || !state.Success() { - t.Fatalf("Wait = %v, %v", state, err) - } - got, err := ReadFile(out.Name()) - if err != nil || string(got) != "ok" { - t.Fatalf("output = %q, %v, want ok", got, err) - } for i, f := range []*File{a, b} { got := make([]byte, 1) _, err := f.ReadAt(got, 0) @@ -100,6 +102,18 @@ func TestForkExecFileRemapping(t *testing.T) { } } +func checkRemapChild(t *testing.T, files []*File, script, want string, success bool) { + t.Helper() + proc, err := StartProcess("/bin/bash", []string{"bash", "-c", script, "bash", want}, &ProcAttr{Files: files}) + if err != nil { + t.Fatalf("StartProcess for %q = %v", script, err) + } + state, err := proc.Wait() + if err != nil || state.Success() != success { + t.Fatalf("Wait for %q = %v, %v, want success %v", script, state, err, success) + } +} + func TestForkExecInvalidRemapSource(t *testing.T) { f, err := Open(DevNull) if err != nil { From b7c3d15627a11abf8bedad9cec70fbacf3776313 Mon Sep 17 00:00:00 2001 From: yohimik Date: Sat, 5 Sep 2026 08:15:57 +0400 Subject: [PATCH 5/5] os: explain the spawn linkname import --- src/os/exec_posix_spawn_darwin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/os/exec_posix_spawn_darwin.go b/src/os/exec_posix_spawn_darwin.go index 1e73753436..e72fe2164a 100644 --- a/src/os/exec_posix_spawn_darwin.go +++ b/src/os/exec_posix_spawn_darwin.go @@ -4,7 +4,7 @@ package os import ( "syscall" - _ "unsafe" + _ "unsafe" // Required by go:linkname. See https://pkg.go.dev/cmd/compile#hdr-Compiler_Directives. ) var spawnDevNull = [...]byte{'/', 'd', 'e', 'v', '/', 'n', 'u', 'l', 'l', 0}