From 8ace10dad2774a760b5028140a7d3f8140e6e1c0 Mon Sep 17 00:00:00 2001 From: Kir Kolyshkin Date: Wed, 27 Aug 2025 22:39:25 -0700 Subject: [PATCH] os: add (*Process).WithHandle Implement (*Process).WithHandle, add tests for all platforms. Fixes #70352 Change-Id: I7a8012fb4e1e1b4ce1e75a59403ff6e77504fc56 Reviewed-on: https://go-review.googlesource.com/c/go/+/699615 LUCI-TryBot-Result: Go LUCI Reviewed-by: Mark Freeman Auto-Submit: Kirill Kolyshkin Reviewed-by: Michael Pratt --- api/next/70352.txt | 2 + doc/next/6-stdlib/99-minor/os/70352.md | 4 ++ src/os/exec.go | 14 +++++ src/os/exec_nohandle_test.go | 40 +++++++++++++++ src/os/exec_plan9.go | 4 ++ src/os/exec_posix.go | 17 ++++++ src/os/exec_windows_test.go | 38 ++++++++++++++ src/os/pidfd_linux_test.go | 71 ++++++++++++++++++++++++++ 8 files changed, 190 insertions(+) create mode 100644 api/next/70352.txt create mode 100644 doc/next/6-stdlib/99-minor/os/70352.md create mode 100644 src/os/exec_nohandle_test.go diff --git a/api/next/70352.txt b/api/next/70352.txt new file mode 100644 index 0000000000..cd264046c3 --- /dev/null +++ b/api/next/70352.txt @@ -0,0 +1,2 @@ +pkg os, method (*Process) WithHandle(func(uintptr)) error #70352 +pkg os, var ErrNoHandle error #70352 diff --git a/doc/next/6-stdlib/99-minor/os/70352.md b/doc/next/6-stdlib/99-minor/os/70352.md new file mode 100644 index 0000000000..5651639dad --- /dev/null +++ b/doc/next/6-stdlib/99-minor/os/70352.md @@ -0,0 +1,4 @@ +The new [Process.WithHandle] method provides access to an internal process +handle on supported platforms (Linux 5.4 or later and Windows). On Linux, +the process handle is a pidfd. The method returns [ErrNoHandle] on unsupported +platforms or when no process handle is available. diff --git a/src/os/exec.go b/src/os/exec.go index e14434d8b5..8b164ad667 100644 --- a/src/os/exec.go +++ b/src/os/exec.go @@ -19,6 +19,8 @@ var ( ErrProcessDone = errors.New("os: process already finished") // errProcessReleased indicates a [Process] has been released. errProcessReleased = errors.New("os: process already released") + // ErrNoHandle indicates a [Process] does not have a handle. + ErrNoHandle = errors.New("os: process handle unavailable") ) // processStatus describes the status of a [Process]. @@ -350,6 +352,18 @@ func (p *Process) Signal(sig Signal) error { return p.signal(sig) } +// WithHandle calls a supplied function f with a valid process handle +// as an argument. The handle is guaranteed to refer to process p +// until f returns, even if p terminates. This function cannot be used +// after [Process.Release] or [Process.Wait]. +// +// If process handles are not supported or a handle is not available, +// it returns [ErrNoHandle]. Currently, process handles are supported +// on Linux 5.4 or later (pidfd) and Windows. +func (p *Process) WithHandle(f func(handle uintptr)) error { + return p.withHandle(f) +} + // UserTime returns the user CPU time of the exited process and its children. func (p *ProcessState) UserTime() time.Duration { return p.userTime() diff --git a/src/os/exec_nohandle_test.go b/src/os/exec_nohandle_test.go new file mode 100644 index 0000000000..73a8ada7eb --- /dev/null +++ b/src/os/exec_nohandle_test.go @@ -0,0 +1,40 @@ +// Copyright 2025 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 && !windows + +package os_test + +import ( + "internal/testenv" + . "os" + "testing" + "time" +) + +func TestProcessWithHandleUnsupported(t *testing.T) { + const envVar = "OSTEST_PROCESS_WITH_HANDLE" + if Getenv(envVar) != "" { + time.Sleep(1 * time.Minute) + return + } + + cmd := testenv.CommandContext(t, t.Context(), testenv.Executable(t), "-test.run=^"+t.Name()+"$") + cmd = testenv.CleanCmdEnv(cmd) + cmd.Env = append(cmd.Env, envVar+"=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + err := cmd.Process.WithHandle(func(handle uintptr) { + t.Errorf("WithHandle: callback called unexpectedly with handle=%v", handle) + }) + if err != ErrNoHandle { + t.Fatalf("WithHandle: got error %v, want %v", err, ErrNoHandle) + } +} diff --git a/src/os/exec_plan9.go b/src/os/exec_plan9.go index 357b925b36..a3d363b344 100644 --- a/src/os/exec_plan9.go +++ b/src/os/exec_plan9.go @@ -88,6 +88,10 @@ func (p *Process) wait() (ps *ProcessState, err error) { return ps, nil } +func (p *Process) withHandle(_ func(handle uintptr)) error { + return ErrNoHandle +} + func findProcess(pid int) (p *Process, err error) { // NOOP for Plan 9. return newPIDProcess(pid), nil diff --git a/src/os/exec_posix.go b/src/os/exec_posix.go index ff51247d56..6b6977ab78 100644 --- a/src/os/exec_posix.go +++ b/src/os/exec_posix.go @@ -77,6 +77,23 @@ func (p *Process) kill() error { return p.Signal(Kill) } +func (p *Process) withHandle(f func(handle uintptr)) error { + if p.handle == nil { + return ErrNoHandle + } + handle, status := p.handleTransientAcquire() + switch status { + case statusDone: + return ErrProcessDone + case statusReleased: + return errProcessReleased + } + defer p.handleTransientRelease() + f(handle) + + return nil +} + // ProcessState stores information about a process, as reported by Wait. type ProcessState struct { pid int // The process's id. diff --git a/src/os/exec_windows_test.go b/src/os/exec_windows_test.go index f8ed4cdf1c..272395ca92 100644 --- a/src/os/exec_windows_test.go +++ b/src/os/exec_windows_test.go @@ -12,7 +12,9 @@ import ( . "os" "path/filepath" "sync" + "syscall" "testing" + "time" ) func TestRemoveAllWithExecutedProcess(t *testing.T) { @@ -81,3 +83,39 @@ func TestRemoveAllWithExecutedProcess(t *testing.T) { } wg.Wait() } + +func TestProcessWithHandleWindows(t *testing.T) { + const envVar = "OSTEST_PROCESS_WITH_HANDLE" + if Getenv(envVar) != "" { + time.Sleep(1 * time.Minute) + return + } + + cmd := testenv.CommandContext(t, t.Context(), testenv.Executable(t), "-test.run=^"+t.Name()+"$") + cmd = testenv.CleanCmdEnv(cmd) + cmd.Env = append(cmd.Env, envVar+"=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + called := false + err := cmd.Process.WithHandle(func(handle uintptr) { + called = true + // Check the handle is valid. + var u syscall.Rusage + e := syscall.GetProcessTimes(syscall.Handle(handle), &u.CreationTime, &u.ExitTime, &u.KernelTime, &u.UserTime) + if e != nil { + t.Errorf("Using process handle failed: %v", NewSyscallError("GetProcessTimes", e)) + } + }) + if err != nil { + t.Fatalf("WithHandle: got error %v, want nil", err) + } + if !called { + t.Fatal("WithHandle did not call the callback function") + } +} diff --git a/src/os/pidfd_linux_test.go b/src/os/pidfd_linux_test.go index 6b10798dd4..0210850c04 100644 --- a/src/os/pidfd_linux_test.go +++ b/src/os/pidfd_linux_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "syscall" "testing" + "time" ) func TestFindProcessViaPidfd(t *testing.T) { @@ -145,3 +146,73 @@ func TestPidfdLeak(t *testing.T) { t.Errorf("got descriptor %d, want %d", got[count-1], want[count-1]) } } + +func TestProcessWithHandleLinux(t *testing.T) { + t.Parallel() + havePidfd := os.CheckPidfdOnce() == nil + + const envVar = "OSTEST_PROCESS_WITH_HANDLE" + if os.Getenv(envVar) != "" { + time.Sleep(1 * time.Minute) + return + } + + cmd := testenv.CommandContext(t, t.Context(), testenv.Executable(t), "-test.run=^"+t.Name()+"$") + cmd = testenv.CleanCmdEnv(cmd) + cmd.Env = append(cmd.Env, envVar+"=1") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { + cmd.Process.Kill() + cmd.Wait() + }() + + const sig = syscall.SIGINT + called := false + err := cmd.Process.WithHandle(func(pidfd uintptr) { + called = true + // Check the provided pidfd is valid, and terminate the child. + err := unix.PidFDSendSignal(pidfd, sig) + if err != nil { + t.Errorf("PidFDSendSignal: got error %v, want nil", err) + } + }) + // If pidfd is not supported, WithHandle should fail. + if !havePidfd && err == nil { + t.Fatal("WithHandle: got nil, want error") + } + // If pidfd is supported, WithHandle should succeed. + if havePidfd && err != nil { + t.Fatalf("WithHandle: got error %v, want nil", err) + } + // If pidfd is supported, function should have been called, and vice versa. + if havePidfd != called { + t.Fatalf("WithHandle: havePidfd is %v, but called is %v", havePidfd, called) + } + // If pidfd is supported, wait on the child process to check it worked as intended. + if called { + err := cmd.Wait() + if err == nil { + t.Fatal("Wait: want error, got nil") + } + st := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !st.Signaled() { + t.Fatal("ProcessState: want Signaled, got", err) + } + if gotSig := st.Signal(); sig != gotSig { + t.Fatalf("ProcessState.Signal: want %v, got %v", sig, gotSig) + } + // Finally, check that WithHandle now returns ErrProcessDone. + called = false + err = cmd.Process.WithHandle(func(_ uintptr) { + called = true + }) + if err != os.ErrProcessDone { + t.Fatalf("WithHandle: want os.ErrProcessDone, got %v", err) + } + if called { + t.Fatal("called: want false, got true") + } + } +} -- 2.52.0