test(agentcompat): add process supervision harness

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:43:28 +00:00
co-authored by naiba/CloudCode
parent 0543995ec3
commit 3a9e0c7887
22 changed files with 3050 additions and 0 deletions
@@ -0,0 +1,46 @@
//go:build linux
package process
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type CleanupRecord struct {
Name string `json:"name"`
PID int `json:"pid"`
Forced bool `json:"forced"`
Error string `json:"error,omitempty"`
}
type CleanupReceipt struct {
Passed bool `json:"passed"`
Forced bool `json:"forced"`
Processes []CleanupRecord `json:"processes"`
}
func NewCleanupReceipt(records []CleanupRecord) CleanupReceipt {
receipt := CleanupReceipt{Passed: true, Processes: append([]CleanupRecord(nil), records...)}
for _, record := range records {
receipt.Forced = receipt.Forced || record.Forced
receipt.Passed = receipt.Passed && record.Error == ""
}
return receipt
}
func WriteCleanupReceipt(path string, receipt CleanupReceipt) error {
data, err := json.MarshalIndent(receipt, "", " ")
if err != nil {
return fmt.Errorf("marshal cleanup receipt: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create cleanup receipt directory: %w", err)
}
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
return fmt.Errorf("write cleanup receipt: %w", err)
}
return nil
}
@@ -0,0 +1,37 @@
//go:build linux
package process
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
)
func ProcessHasOpenPath(pid int, path string) (bool, error) {
if pid < 1 || !filepath.IsAbs(path) {
return false, errors.New("invalid process path query")
}
directory := filepath.Join("/proc", strconv.Itoa(pid), "fd")
entries, err := os.ReadDir(directory)
if err != nil {
return false, err
}
wanted := filepath.Clean(path)
for _, entry := range entries {
target, err := os.Readlink(filepath.Join(directory, entry.Name()))
if err != nil {
if os.IsNotExist(err) {
continue
}
return false, err
}
target = strings.TrimSuffix(target, " (deleted)")
if filepath.IsAbs(target) && filepath.Clean(target) == wanted {
return true, nil
}
}
return false, nil
}
@@ -0,0 +1,30 @@
//go:build linux
package process
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func TestProcessHasOpenPathTogglesWithDescriptorLifecycle(t *testing.T) {
// Given
path := filepath.Join(t.TempDir(), "dashboard.sqlite-journal")
require.NoError(t, os.WriteFile(path, []byte("journal"), 0o600))
file, err := os.Open(path)
require.NoError(t, err)
// When
held, err := ProcessHasOpenPath(os.Getpid(), path)
require.NoError(t, err)
require.NoError(t, file.Close())
released, err := ProcessHasOpenPath(os.Getpid(), path)
// Then
require.NoError(t, err)
require.True(t, held)
require.False(t, released)
}
@@ -0,0 +1,231 @@
//go:build linux
package process
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
const (
helperModeEnv = "NEZHA_AGENTCOMPAT_PROCESS_HELPER"
helperMarkerEnv = "NEZHA_AGENTCOMPAT_PROCESS_MARKER"
helperFDEnv = "NEZHA_AGENTCOMPAT_PROCESS_FD"
)
func TestProcessHelper(t *testing.T) {
switch os.Getenv(helperModeEnv) {
case "":
return
case "clean":
fmt.Println("READY")
case "credential":
marker := os.Getenv(helperMarkerEnv)
if err := os.WriteFile(marker, []byte(fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())), 0o600); err != nil {
t.Fatal(err)
}
case "block":
fmt.Println("READY")
_, _ = io.Copy(io.Discard, os.Stdin)
case "tree":
runTreeHelper(t, false)
case "force-tree":
runTreeHelper(t, true)
case "grandchild":
runGrandchildHelper(t)
case "ignore-term-grandchild":
signal.Ignore(syscall.SIGTERM)
runGrandchildHelper(t)
case "ignore-term":
signal.Ignore(syscall.SIGTERM)
fmt.Println("READY")
waitForSignal(syscall.SIGINT)
case "listener":
runListenerHelper(t)
case "logs":
fmt.Println("READY")
fmt.Println("Authorization: Bearer eyJsecret.secret.secret password=top-secret")
fmt.Println(strings.Repeat("x", 1024))
case "interrupt-probe":
runInterruptProbeHelper(t)
default:
t.Fatalf("unknown helper mode %q", os.Getenv(helperModeEnv))
}
}
func runInterruptProbeHelper(t *testing.T) {
t.Helper()
marker := os.Getenv(helperMarkerEnv)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
defer stop()
supervisor := newHelperSupervisor(ctx, "tree", []string{helperMarkerEnv + "=" + marker})
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
grandchildPID := readPID(t, marker)
if err := os.WriteFile(marker+".leader", []byte(strconv.Itoa(supervisor.PID())), 0o600); err != nil {
t.Fatal(err)
}
fmt.Println("PROBE_READY")
<-ctx.Done()
select {
case <-supervisor.cleanupDone:
case <-time.After(2 * time.Second):
t.Fatal("context cancellation did not complete process-tree cleanup")
}
requirePIDGone(t, supervisor.PID())
requirePIDGone(t, grandchildPID)
}
func runTreeHelper(t *testing.T, ignoreTermination bool) {
t.Helper()
child := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
childMode := "grandchild"
if ignoreTermination {
childMode = "ignore-term-grandchild"
signal.Ignore(syscall.SIGTERM)
}
child.Env = append(os.Environ(), helperModeEnv+"="+childMode)
child.Stdout = os.Stdout
child.Stderr = os.Stderr
if err := child.Start(); err != nil {
t.Fatal(err)
}
if ignoreTermination {
waitForSignal(syscall.SIGINT)
_ = child.Wait()
return
}
waitForSignal(syscall.SIGTERM)
_ = child.Wait()
}
func runGrandchildHelper(t *testing.T) {
t.Helper()
marker := os.Getenv(helperMarkerEnv)
if marker == "" {
t.Fatal("helper marker is empty")
}
if err := os.WriteFile(marker, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
t.Fatal(err)
}
fmt.Println("READY")
waitForSignal(syscall.SIGTERM)
}
func runListenerHelper(t *testing.T) {
t.Helper()
descriptor, err := strconv.Atoi(os.Getenv(helperFDEnv))
if err != nil {
t.Fatal(err)
}
file := os.NewFile(uintptr(descriptor), "inherited-listener")
listener, err := net.FileListener(file)
if err != nil {
t.Fatal(err)
}
_ = file.Close()
defer listener.Close()
fmt.Println("READY")
waitForSignal(syscall.SIGTERM)
}
func waitForSignal(expected os.Signal) {
signals := make(chan os.Signal, 1)
signal.Notify(signals, expected)
defer signal.Stop(signals)
<-signals
}
func newHelperSupervisor(ctx context.Context, mode string, environment []string) *Supervisor {
return NewSupervisor(ctx, Spec{
Name: "helper-" + mode,
Path: os.Args[0],
Args: []string{"-test.run=^TestProcessHelper$"},
Env: append(append(os.Environ(), helperModeEnv+"="+mode), environment...),
MaxLogBytes: 1024,
TerminateTimeout: 100 * time.Millisecond,
KillTimeout: time.Second,
Stdout: os.Stdout,
Stderr: os.Stderr,
Readiness: func(_ Stream, line string) bool {
return strings.Contains(line, "READY")
},
})
}
func startBlockingHelper(t *testing.T) (*exec.Cmd, func()) {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
command.Env = append(os.Environ(), helperModeEnv+"=block")
input, err := command.StdinPipe()
requireNoError(t, err)
output, err := command.StdoutPipe()
requireNoError(t, err)
requireNoError(t, command.Start())
scanner := bufio.NewScanner(output)
if !scanner.Scan() || scanner.Text() != "READY" {
t.Fatalf("helper readiness = %q, err = %v", scanner.Text(), scanner.Err())
}
return command, func() { _ = input.Close() }
}
func startCleanHelper(t *testing.T) *exec.Cmd {
t.Helper()
command := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
command.Env = append(os.Environ(), helperModeEnv+"=clean")
requireNoError(t, command.Start())
return command
}
func reapHelper(command *exec.Cmd) {
if command.ProcessState == nil {
_ = command.Process.Kill()
_ = command.Wait()
}
}
func readPID(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
requireNoError(t, err)
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
requireNoError(t, err)
return pid
}
func requirePIDGone(t *testing.T, pid int) {
t.Helper()
_, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid)))
if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("PID %d remains: %v", pid, err)
}
}
func containsPID(pids []int, target int) bool {
for _, pid := range pids {
if pid == target {
return true
}
}
return false
}
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
@@ -0,0 +1,127 @@
//go:build linux
package process
import (
"bytes"
"errors"
"fmt"
"io"
"sync"
"github.com/nezhahq/nezha/integration/agentcompat/internal/evidence"
)
const truncationMarker = "[TRUNCATED]\n"
type boundedLog struct {
mu sync.Mutex
destination io.Writer
maxBytes int
written int
pending []byte
dropLine bool
truncated bool
closed bool
onLine func(string)
writeErr error
}
func newBoundedLog(destination io.Writer, maxBytes int, onLine func(string)) *boundedLog {
return &boundedLog{destination: destination, maxBytes: maxBytes, onLine: onLine}
}
func (log *boundedLog) Write(data []byte) (int, error) {
log.mu.Lock()
defer log.mu.Unlock()
if log.closed {
return 0, errors.New("write closed process log")
}
inputLength := len(data)
for len(data) > 0 {
newline := bytes.IndexByte(data, '\n')
if newline < 0 {
log.appendFragment(data)
break
}
log.appendFragment(data[:newline+1])
if err := log.flushLine(); err != nil {
return 0, err
}
data = data[newline+1:]
}
return inputLength, nil
}
func (log *boundedLog) appendFragment(fragment []byte) {
if log.dropLine {
return
}
if len(log.pending)+len(fragment) > log.maxBytes {
log.pending = nil
log.dropLine = true
log.truncated = true
return
}
log.pending = append(log.pending, fragment...)
}
func (log *boundedLog) flushLine() error {
if log.dropLine {
log.dropLine = false
return log.writeMarker()
}
redacted := evidence.Redact(string(log.pending))
log.pending = nil
if log.onLine != nil {
log.onLine(redacted)
}
if len(redacted) > log.maxBytes-log.written {
log.truncated = true
return log.writeMarker()
}
if log.destination != nil && redacted != "" {
written, err := io.WriteString(log.destination, redacted)
log.written += written
if err != nil {
log.writeErr = fmt.Errorf("write process log: %w", err)
return log.writeErr
}
}
return nil
}
func (log *boundedLog) writeMarker() error {
if log.destination == nil || log.written >= log.maxBytes {
return nil
}
marker := truncationMarker
if len(marker) > log.maxBytes-log.written {
marker = marker[:log.maxBytes-log.written]
}
written, err := io.WriteString(log.destination, marker)
log.written += written
if err != nil {
log.writeErr = fmt.Errorf("write process log marker: %w", err)
return log.writeErr
}
return nil
}
func (log *boundedLog) Close() {
log.mu.Lock()
defer log.mu.Unlock()
if log.closed {
return
}
if len(log.pending) > 0 || log.dropLine {
_ = log.flushLine()
}
log.closed = true
}
func (log *boundedLog) Truncated() bool {
log.mu.Lock()
defer log.mu.Unlock()
return log.truncated
}
@@ -0,0 +1,159 @@
//go:build linux
package process
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
)
func readRSSBytes(pid int) (uint64, error) {
path := filepath.Join("/proc", strconv.Itoa(pid), "status")
file, err := os.Open(path)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 3 && fields[0] == "VmRSS:" && fields[2] == "kB" {
kilobytes, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse VmRSS: %w", err)
}
return kilobytes * 1024, nil
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("read %s: %w", path, err)
}
return 0, errors.New("VmRSS not found")
}
func descendantPIDs(rootPID int) ([]int, error) {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil, fmt.Errorf("read /proc: %w", err)
}
children := make(map[int][]int)
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil || !entry.IsDir() {
continue
}
parentPID, err := readParentPID(pid)
if err != nil {
// /proc is a live snapshot: an unrelated process can disappear
// between ReadDir and reading stat. Root PID reads stay strict.
if os.IsNotExist(err) || errors.Is(err, syscall.ESRCH) {
continue
}
return nil, err
}
children[parentPID] = append(children[parentPID], pid)
}
descendants := make([]int, 0)
queue := append([]int(nil), children[rootPID]...)
for len(queue) > 0 {
pid := queue[0]
queue = queue[1:]
descendants = append(descendants, pid)
queue = append(queue, children[pid]...)
}
sort.Ints(descendants)
return descendants, nil
}
func readParentPID(pid int) (int, error) {
path := filepath.Join("/proc", strconv.Itoa(pid), "stat")
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
closingParenthesis := strings.LastIndexByte(string(data), ')')
if closingParenthesis < 0 {
return 0, fmt.Errorf("parse %s: missing command terminator", path)
}
fields := strings.Fields(string(data[closingParenthesis+1:]))
if len(fields) < 2 {
return 0, fmt.Errorf("parse %s: missing parent PID", path)
}
parentPID, err := strconv.Atoi(fields[1])
if err != nil {
return 0, fmt.Errorf("parse %s parent PID: %w", path, err)
}
return parentPID, nil
}
func processFDs(pid int) (int, map[uint64]struct{}, error) {
directory := filepath.Join("/proc", strconv.Itoa(pid), "fd")
entries, err := os.ReadDir(directory)
if err != nil {
return 0, nil, err
}
count := 0
sockets := make(map[uint64]struct{})
for _, entry := range entries {
descriptor, err := strconv.Atoi(entry.Name())
if err != nil || descriptor < 3 {
continue
}
target, err := os.Readlink(filepath.Join(directory, entry.Name()))
if err != nil {
if os.IsNotExist(err) {
continue
}
return 0, nil, err
}
count++
if inode, exists := parseSocketInode(target); exists {
sockets[inode] = struct{}{}
}
}
return count, sockets, nil
}
func parseSocketInode(target string) (uint64, bool) {
if !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") {
return 0, false
}
inode, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]"), 10, 64)
return inode, err == nil
}
func listeningSocketInodes(pid int, protocol string) (map[uint64]struct{}, error) {
path := filepath.Join("/proc", strconv.Itoa(pid), "net", protocol)
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
listeners := make(map[uint64]struct{})
scanner := bufio.NewScanner(file)
if scanner.Scan() {
// Skip the stable kernel table header.
}
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 10 || fields[3] != "0A" {
continue
}
inode, err := strconv.ParseUint(fields[9], 10, 64)
if err != nil {
return nil, fmt.Errorf("parse %s listener inode: %w", path, err)
}
listeners[inode] = struct{}{}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
return listeners, nil
}
@@ -0,0 +1,109 @@
//go:build linux
package process
import (
"context"
"errors"
"fmt"
"os"
"time"
"github.com/nezhahq/nezha/integration/agentcompat/internal/contract"
)
type Sample struct {
PID int `json:"pid"`
RSSBytes uint64 `json:"rss_bytes"`
DescendantPIDs []int `json:"descendant_pids"`
DescendantCount int `json:"descendant_count"`
NonStdioFDCount int `json:"non_stdio_fd_count"`
TCPListenerCount int `json:"tcp_listener_count"`
TCP6ListenerCount int `json:"tcp6_listener_count"`
}
type Window struct {
PID int `json:"pid"`
Samples []Sample `json:"samples"`
}
type WindowSpec struct {
PID int
Interval time.Duration
AllowTerminated bool
ObserveSample func(context.Context, Sample) error
}
func SampleProcess(pid int) (Sample, error) {
rssBytes, err := readRSSBytes(pid)
if err != nil {
return Sample{}, err
}
descendants, err := descendantPIDs(pid)
if err != nil {
return Sample{}, err
}
fdCount, socketInodes, err := processFDs(pid)
if err != nil {
return Sample{}, err
}
tcpListeners, err := listeningSocketInodes(pid, "tcp")
if err != nil {
return Sample{}, err
}
tcp6Listeners, err := listeningSocketInodes(pid, "tcp6")
if err != nil {
return Sample{}, err
}
return Sample{
PID: pid,
RSSBytes: rssBytes,
DescendantPIDs: descendants,
DescendantCount: len(descendants),
NonStdioFDCount: fdCount,
TCPListenerCount: intersectionCount(socketInodes, tcpListeners),
TCP6ListenerCount: intersectionCount(socketInodes, tcp6Listeners),
}, nil
}
func SampleWindow(ctx context.Context, spec WindowSpec) (Window, error) {
if spec.PID < 1 || spec.Interval <= 0 {
return Window{}, errors.New("invalid sample window specification")
}
window := Window{PID: spec.PID, Samples: make([]Sample, 0, contract.ResourceSampleCount)}
for index := 0; index < contract.ResourceSampleCount; index++ {
if index > 0 {
timer := time.NewTimer(spec.Interval)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return Window{}, ctx.Err()
}
}
sample, err := SampleProcess(spec.PID)
if err != nil {
if spec.AllowTerminated && os.IsNotExist(err) {
return window, nil
}
return Window{}, fmt.Errorf("sample %d of PID %d: %w", index+1, spec.PID, err)
}
window.Samples = append(window.Samples, sample)
if spec.ObserveSample != nil {
if err := spec.ObserveSample(ctx, sample); err != nil {
return window, err
}
}
}
return window, nil
}
func intersectionCount(left, right map[uint64]struct{}) int {
count := 0
for value := range left {
if _, exists := right[value]; exists {
count++
}
}
return count
}
@@ -0,0 +1,209 @@
//go:build linux
package process
import (
"context"
"errors"
"net"
"os"
"os/exec"
"reflect"
"testing"
"time"
)
func TestSampler_ReadsRSS(t *testing.T) {
// Given / When
sample, err := SampleProcess(os.Getpid())
// Then
requireNoError(t, err)
if sample.RSSBytes == 0 {
t.Fatal("RSS is zero")
}
}
func TestSampler_CountsDescendants(t *testing.T) {
// Given
child, closeInput := startBlockingHelper(t)
defer closeInput()
defer reapHelper(child)
// When
sample, err := SampleProcess(os.Getpid())
// Then
requireNoError(t, err)
if !containsPID(sample.DescendantPIDs, child.Process.Pid) {
t.Fatalf("descendants = %v, want PID %d", sample.DescendantPIDs, child.Process.Pid)
}
}
func TestSampler_CountsNonStdioFDs(t *testing.T) {
// Given
baseline, err := SampleProcess(os.Getpid())
requireNoError(t, err)
file, err := os.Open("/proc/self/status")
requireNoError(t, err)
t.Cleanup(func() { _ = file.Close() })
// When
sample, err := SampleProcess(os.Getpid())
// Then
requireNoError(t, err)
if sample.NonStdioFDCount != baseline.NonStdioFDCount+1 {
t.Fatalf("non-stdio FDs = %d, baseline = %d", sample.NonStdioFDCount, baseline.NonStdioFDCount)
}
}
func TestSampler_CountsTCPListeners(t *testing.T) {
// Given
baseline, err := SampleProcess(os.Getpid())
requireNoError(t, err)
listener, err := net.Listen("tcp4", "127.0.0.1:0")
requireNoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
// When
sample, err := SampleProcess(os.Getpid())
// Then
requireNoError(t, err)
if sample.TCPListenerCount != baseline.TCPListenerCount+1 {
t.Fatalf("TCP listeners = %d, baseline = %d", sample.TCPListenerCount, baseline.TCPListenerCount)
}
}
func TestSampler_CountsTCP6Listeners(t *testing.T) {
// Given
baseline, err := SampleProcess(os.Getpid())
requireNoError(t, err)
listener, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback listener unavailable: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
// When
sample, err := SampleProcess(os.Getpid())
// Then
requireNoError(t, err)
if sample.TCP6ListenerCount != baseline.TCP6ListenerCount+1 {
t.Fatalf("TCP6 listeners = %d, baseline = %d", sample.TCP6ListenerCount, baseline.TCP6ListenerCount)
}
}
func TestSampler_CollectsFiveSampleWindow(t *testing.T) {
// Given
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
// When
window, err := SampleWindow(ctx, WindowSpec{PID: os.Getpid(), Interval: time.Millisecond})
// Then
requireNoError(t, err)
if len(window.Samples) != 5 {
t.Fatalf("samples = %d, want 5", len(window.Samples))
}
}
func TestSampleWindow_InvokesObserverAfterEachSuccessfulAppend(t *testing.T) {
// Given
observed := make([]Sample, 0, 5)
// When
window, err := SampleWindow(t.Context(), WindowSpec{PID: os.Getpid(), Interval: time.Millisecond, ObserveSample: func(_ context.Context, sample Sample) error {
observed = append(observed, sample)
return nil
}})
// Then
requireNoError(t, err)
if len(window.Samples) != 5 || len(observed) != 5 {
t.Fatalf("samples = %d, observed = %d, want 5", len(window.Samples), len(observed))
}
for index := range window.Samples {
if !reflect.DeepEqual(window.Samples[index], observed[index]) {
t.Fatalf("sample %d was not observed after append", index+1)
}
}
}
func TestSampleWindow_ReturnsAppendedSampleWhenObserverFails(t *testing.T) {
// Given
observerErr := errors.New("observer failed")
calls := 0
// When
window, err := SampleWindow(t.Context(), WindowSpec{PID: os.Getpid(), Interval: time.Millisecond, ObserveSample: func(context.Context, Sample) error {
calls++
return observerErr
}})
// Then
if !errors.Is(err, observerErr) {
t.Fatalf("observer error = %v, want %v", err, observerErr)
}
if calls != 1 || len(window.Samples) != 1 {
t.Fatalf("calls = %d, samples = %d, want one appended sample", calls, len(window.Samples))
}
}
func TestSampler_RejectsVanishedPIDDuringWindow(t *testing.T) {
// Given
child := startCleanHelper(t)
requireNoError(t, child.Wait())
// When
_, err := SampleWindow(t.Context(), WindowSpec{PID: child.Process.Pid, Interval: time.Millisecond})
// Then
if err == nil {
t.Fatal("vanished PID was accepted")
}
}
func TestSampler_AllowsExplicitlyTerminatedPID(t *testing.T) {
// Given
child := startCleanHelper(t)
requireNoError(t, child.Wait())
// When
window, err := SampleWindow(t.Context(), WindowSpec{PID: child.Process.Pid, Interval: time.Millisecond, AllowTerminated: true})
// Then
requireNoError(t, err)
if len(window.Samples) != 0 {
t.Fatalf("samples = %d, want 0", len(window.Samples))
}
}
func TestSampler_ToleratesVanishedUnrelatedProcEntries(t *testing.T) {
// Given
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
churnDone := make(chan struct{})
go func() {
defer close(churnDone)
for ctx.Err() == nil {
command := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
command.Env = append(os.Environ(), helperModeEnv+"=clean")
if err := command.Run(); err != nil {
return
}
}
}()
// When
for range 100 {
if _, err := SampleProcess(os.Getpid()); err != nil {
t.Fatalf("sample during /proc churn: %v", err)
}
}
cancel()
<-churnDone
}
@@ -0,0 +1,76 @@
//go:build linux
package process
import (
"errors"
"sync"
"golang.org/x/sys/unix"
)
var (
ErrSQLiteJournalUnsupported = errors.New("sqlite journal identity unsupported")
ErrSQLiteJournalIdentityMismatch = errors.New("sqlite journal identity mismatch")
)
type SQLiteJournalUnsupportedError struct{ Missing uint32 }
func (err *SQLiteJournalUnsupportedError) Error() string { return ErrSQLiteJournalUnsupported.Error() }
func (err *SQLiteJournalUnsupportedError) Unwrap() error { return ErrSQLiteJournalUnsupported }
type SQLiteJournalIdentity struct {
MountID uint64
DeviceMajor uint32
DeviceMinor uint32
Inode uint64
BirthTime unix.StatxTimestamp
}
func (identity SQLiteJournalIdentity) equal(other SQLiteJournalIdentity) bool {
return identity == other
}
func sqliteJournalIdentity(stat unix.Statx_t) (SQLiteJournalIdentity, error) {
required := uint32(unix.STATX_MNT_ID | unix.STATX_BTIME)
if stat.Mask&required != required {
return SQLiteJournalIdentity{}, &SQLiteJournalUnsupportedError{Missing: required &^ stat.Mask}
}
return SQLiteJournalIdentity{
MountID: stat.Mnt_id,
DeviceMajor: stat.Dev_major,
DeviceMinor: stat.Dev_minor,
Inode: stat.Ino,
BirthTime: stat.Btime,
}, nil
}
func readSQLiteJournalIdentity(fd int) (SQLiteJournalIdentity, error) {
var stat unix.Statx_t
err := unix.Statx(fd, "", unix.AT_EMPTY_PATH, unix.STATX_BASIC_STATS|unix.STATX_MNT_ID|unix.STATX_BTIME, &stat)
if err != nil {
if errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.EPERM) {
return SQLiteJournalIdentity{}, &SQLiteJournalUnsupportedError{Missing: unix.STATX_MNT_ID | unix.STATX_BTIME}
}
return SQLiteJournalIdentity{}, err
}
if stat.Mode&unix.S_IFMT != unix.S_IFREG {
return SQLiteJournalIdentity{}, &SQLiteJournalUnsupportedError{}
}
return sqliteJournalIdentity(stat)
}
type SQLiteJournalIdentityError struct {
Expected SQLiteJournalIdentity
Actual SQLiteJournalIdentity
}
func (err *SQLiteJournalIdentityError) Error() string {
return ErrSQLiteJournalIdentityMismatch.Error()
}
func (err *SQLiteJournalIdentityError) Unwrap() error { return ErrSQLiteJournalIdentityMismatch }
type sqliteJournalCloser struct {
once sync.Once
err error
}
@@ -0,0 +1,195 @@
//go:build linux
package process
import (
"bytes"
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"unsafe"
"golang.org/x/sys/unix"
)
var ErrSQLiteJournalLifecycle = errors.New("invalid sqlite journal lifecycle")
type SQLiteJournalLifecycleError struct{ Event uint32 }
func (err *SQLiteJournalLifecycleError) Error() string { return ErrSQLiteJournalLifecycle.Error() }
func (err *SQLiteJournalLifecycleError) Unwrap() error { return ErrSQLiteJournalLifecycle }
type SQLiteJournalWatch struct {
path string
journalFD int
inotifyFD int
identity SQLiteJournalIdentity
journalWD int
directoryWD int
journalName []byte
closed sqliteJournalCloser
mu sync.Mutex
closeSeen bool
deleted bool
}
func OpenSQLiteJournalWatch(path string) (*SQLiteJournalWatch, error) {
journalFD, err := unix.Open(path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
identity, err := readSQLiteJournalIdentity(journalFD)
if err != nil {
_ = unix.Close(journalFD)
return nil, err
}
inotifyFD, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
if err != nil {
_ = unix.Close(journalFD)
return nil, err
}
journalWD, err := unix.InotifyAddWatch(inotifyFD, fmt.Sprintf("/proc/self/fd/%d", journalFD), unix.IN_CLOSE_WRITE|unix.IN_DELETE_SELF|unix.IN_MOVE_SELF|unix.IN_UNMOUNT)
if err != nil {
_ = unix.Close(inotifyFD)
_ = unix.Close(journalFD)
return nil, err
}
directoryWD, err := unix.InotifyAddWatch(inotifyFD, filepath.Dir(path), unix.IN_DELETE|unix.IN_UNMOUNT)
if err != nil {
_ = unix.Close(inotifyFD)
_ = unix.Close(journalFD)
return nil, err
}
watch := &SQLiteJournalWatch{path: path, journalFD: journalFD, inotifyFD: inotifyFD, identity: identity, journalWD: journalWD, directoryWD: directoryWD, journalName: []byte(filepath.Base(path))}
if err := watch.Verify(); err != nil {
_ = watch.Close()
return nil, err
}
return watch, nil
}
func (watch *SQLiteJournalWatch) Identity() SQLiteJournalIdentity { return watch.identity }
func (watch *SQLiteJournalWatch) ObserveSample(context.Context, Sample) error { return watch.Verify() }
func (watch *SQLiteJournalWatch) Verify() error {
fd, err := unix.Open(watch.path, unix.O_PATH|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return &SQLiteJournalIdentityError{Expected: watch.identity}
}
defer unix.Close(fd)
actual, err := readSQLiteJournalIdentity(fd)
if err != nil {
return err
}
if !watch.identity.equal(actual) {
return &SQLiteJournalIdentityError{Expected: watch.identity, Actual: actual}
}
return nil
}
func (watch *SQLiteJournalWatch) Wait(ctx context.Context) error {
cancelFD, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK)
if err != nil {
return err
}
defer unix.Close(cancelFD)
stop := make(chan struct{})
var done sync.WaitGroup
done.Add(1)
go func() {
defer done.Done()
select {
case <-ctx.Done():
_, _ = unix.Write(cancelFD, []byte{1, 0, 0, 0, 0, 0, 0, 0})
case <-stop:
}
}()
defer func() { close(stop); done.Wait() }()
for {
fds := []unix.PollFd{{Fd: int32(watch.inotifyFD), Events: unix.POLLIN}, {Fd: int32(cancelFD), Events: unix.POLLIN}}
if _, err := unix.Poll(fds, -1); err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
return err
}
if fds[1].Revents&unix.POLLIN != 0 {
return ctx.Err()
}
if err := watch.readEvents(); err != nil {
return err
}
watch.mu.Lock()
completed := watch.deleted
watch.mu.Unlock()
if completed {
return nil
}
}
}
func (watch *SQLiteJournalWatch) readEvents() error {
var buffer [unix.SizeofInotifyEvent * 8]byte
count, err := unix.Read(watch.inotifyFD, buffer[:])
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
return nil
}
if err != nil {
return err
}
for offset := 0; offset+unix.SizeofInotifyEvent <= count; {
event := (*unix.InotifyEvent)(unsafe.Pointer(&buffer[offset]))
next := offset + unix.SizeofInotifyEvent + int(event.Len)
if next > count {
return &SQLiteJournalLifecycleError{}
}
nameStart := offset + unix.SizeofInotifyEvent
name := bytes.TrimRight(buffer[nameStart:next], "\x00")
if err := watch.observeEvent(event.Wd, event.Mask, name); err != nil {
return err
}
offset = next
}
return nil
}
func (watch *SQLiteJournalWatch) observeEvent(watchDescriptor int32, mask uint32, name []byte) error {
if int(watchDescriptor) == watch.directoryWD && mask&unix.IN_DELETE != 0 && bytes.Equal(name, watch.journalName) {
return watch.observe(unix.IN_DELETE_SELF)
}
if int(watchDescriptor) != watch.journalWD {
return nil
}
return watch.observe(mask)
}
func (watch *SQLiteJournalWatch) observe(mask uint32) error {
watch.mu.Lock()
defer watch.mu.Unlock()
if mask&(unix.IN_Q_OVERFLOW|unix.IN_MOVE_SELF|unix.IN_UNMOUNT) != 0 || mask&unix.IN_IGNORED != 0 && !watch.deleted {
return &SQLiteJournalLifecycleError{Event: mask}
}
if mask&unix.IN_CLOSE_WRITE != 0 {
if watch.closeSeen || watch.deleted {
return &SQLiteJournalLifecycleError{Event: mask}
}
watch.closeSeen = true
}
if mask&unix.IN_DELETE_SELF != 0 {
if !watch.closeSeen || watch.deleted {
return &SQLiteJournalLifecycleError{Event: mask}
}
watch.deleted = true
}
return nil
}
func (watch *SQLiteJournalWatch) Close() error {
watch.closed.once.Do(func() {
watch.closed.err = errors.Join(unix.Close(watch.inotifyFD), unix.Close(watch.journalFD))
})
return watch.closed.err
}
@@ -0,0 +1,186 @@
//go:build linux
package process
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/sys/unix"
)
func TestSQLiteJournalWatch_CapturesExactIdentityAndCloses(t *testing.T) {
// Given
path := writeJournal(t, "dashboard.sqlite-journal")
watch, err := OpenSQLiteJournalWatch(path)
requireNoError(t, err)
t.Cleanup(func() { requireNoError(t, watch.Close()) })
// When
identity := watch.Identity()
// Then
if identity.MountID == 0 || identity.Inode == 0 || identity.BirthTime.Sec == 0 {
t.Fatalf("identity = %#v, want complete statx identity", identity)
}
if err := watch.Verify(); err != nil {
t.Fatalf("verify identity: %v", err)
}
journalFD, inotifyFD := watch.journalFD, watch.inotifyFD
requireNoError(t, watch.Close())
requireNoError(t, watch.Close())
if _, err := unix.FcntlInt(uintptr(journalFD), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
t.Fatalf("journal descriptor remains open: %v", err)
}
if _, err := unix.FcntlInt(uintptr(inotifyFD), unix.F_GETFD, 0); !errors.Is(err, unix.EBADF) {
t.Fatalf("inotify descriptor remains open: %v", err)
}
}
func TestSQLiteJournalWatch_RejectsReplacementPathDrift(t *testing.T) {
// Given
path := writeJournal(t, "dashboard.sqlite-journal")
watch, err := OpenSQLiteJournalWatch(path)
requireNoError(t, err)
t.Cleanup(func() { requireNoError(t, watch.Close()) })
replacement := filepath.Join(filepath.Dir(path), "replacement")
requireNoError(t, os.WriteFile(replacement, []byte("replacement"), 0o600))
requireNoError(t, os.Rename(replacement, path))
// When
err = watch.Verify()
// Then
if !errors.Is(err, ErrSQLiteJournalIdentityMismatch) {
t.Fatalf("verify error = %v, want identity mismatch", err)
}
}
func TestSQLiteJournalWatch_VerifiesIdentityForEveryWindowSample(t *testing.T) {
// Given
path := writeJournal(t, "dashboard.sqlite-journal")
watch, err := OpenSQLiteJournalWatch(path)
requireNoError(t, err)
t.Cleanup(func() { requireNoError(t, watch.Close()) })
verified := 0
// When
window, err := SampleWindow(t.Context(), WindowSpec{PID: os.Getpid(), Interval: time.Millisecond, ObserveSample: func(ctx context.Context, sample Sample) error {
verified++
return watch.ObserveSample(ctx, sample)
}})
// Then
requireNoError(t, err)
if len(window.Samples) != 5 || verified != 5 {
t.Fatalf("samples = %d, verified = %d, want 5", len(window.Samples), verified)
}
}
func TestSQLiteJournalIdentity_RejectsMissingRequiredStatxMask(t *testing.T) {
// Given
stat := unix.Statx_t{Mask: unix.STATX_MNT_ID, Mnt_id: 1, Ino: 2}
// When / Then
if _, err := sqliteJournalIdentity(stat); !errors.Is(err, ErrSQLiteJournalUnsupported) {
t.Fatalf("birth-time error = %v, want unsupported", err)
}
stat.Mask = unix.STATX_BTIME
if _, err := sqliteJournalIdentity(stat); !errors.Is(err, ErrSQLiteJournalUnsupported) {
t.Fatalf("mount-ID error = %v, want unsupported", err)
}
}
func TestSQLiteJournalWatch_RejectsInvalidLifecycleEvents(t *testing.T) {
for name, mask := range map[string]uint32{
"overflow": unix.IN_Q_OVERFLOW,
"move self": unix.IN_MOVE_SELF,
"unmount": unix.IN_UNMOUNT,
"ignored": unix.IN_IGNORED,
"missing close": unix.IN_DELETE_SELF,
} {
t.Run(name, func(t *testing.T) {
// Given
watch := &SQLiteJournalWatch{}
// When
err := watch.observe(mask)
// Then
if err == nil {
t.Fatal("invalid lifecycle event was accepted")
}
})
}
}
func TestSQLiteJournalWatch_RejectsDuplicateTerminalEvent(t *testing.T) {
// Given
watch := &SQLiteJournalWatch{}
requireNoError(t, watch.observe(unix.IN_CLOSE_WRITE))
requireNoError(t, watch.observe(unix.IN_DELETE_SELF))
// When
err := watch.observe(unix.IN_DELETE_SELF)
// Then
if !errors.Is(err, ErrSQLiteJournalLifecycle) {
t.Fatalf("duplicate terminal error = %v, want lifecycle error", err)
}
}
func TestSQLiteJournalWatch_WaitsForCloseThenDeleteAndCancellation(t *testing.T) {
// Given
path := writeJournal(t, "dashboard.sqlite-journal")
watch, err := OpenSQLiteJournalWatch(path)
requireNoError(t, err)
t.Cleanup(func() { requireNoError(t, watch.Close()) })
ctx, cancel := context.WithCancel(t.Context())
result := make(chan error, 1)
go func() { result <- watch.Wait(ctx) }()
// When
cancel()
err = <-result
// Then
if !errors.Is(err, context.Canceled) {
t.Fatalf("wait error = %v, want cancellation", err)
}
if err := watch.observe(unix.IN_CLOSE_WRITE); err != nil {
t.Fatalf("close write: %v", err)
}
if err := watch.observe(unix.IN_DELETE_SELF); err != nil {
t.Fatalf("delete self: %v", err)
}
}
func TestSQLiteJournalWatch_WaitsForExactCloseDeleteLifecycle(t *testing.T) {
// Given
path := writeJournal(t, "dashboard.sqlite-journal")
watch, err := OpenSQLiteJournalWatch(path)
requireNoError(t, err)
t.Cleanup(func() { requireNoError(t, watch.Close()) })
result := make(chan error, 1)
go func() { result <- watch.Wait(t.Context()) }()
journal, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
requireNoError(t, err)
requireNoError(t, journal.Close())
requireNoError(t, os.Remove(path))
// When
err = <-result
// Then
requireNoError(t, err)
}
func writeJournal(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
requireNoError(t, os.WriteFile(path, []byte("journal"), 0o600))
return path
}
@@ -0,0 +1,278 @@
//go:build linux
package process
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"sync"
"syscall"
"time"
)
type Stream string
const (
Stdout Stream = "stdout"
Stderr Stream = "stderr"
)
type Spec struct {
Name string
Path string
Args []string
Dir string
Env []string
ExtraFiles []*os.File
Stdout io.Writer
Stderr io.Writer
MaxLogBytes int
TerminateTimeout time.Duration
KillTimeout time.Duration
Readiness func(Stream, string) bool
Credential *syscall.Credential
}
type Supervisor struct {
ctx context.Context
spec Spec
cmd *exec.Cmd
pid int
pgid int
ready chan struct{}
readyOnce sync.Once
exited chan struct{}
waitErr error
waitMu sync.Mutex
cleanupOnce sync.Once
cleanupDone chan struct{}
cleanupErr error
forced bool
stateMu sync.Mutex
stdoutLog *boundedLog
stderrLog *boundedLog
}
func NewSupervisor(ctx context.Context, spec Spec) *Supervisor {
return &Supervisor{ctx: ctx, spec: spec, ready: make(chan struct{}), exited: make(chan struct{}), cleanupDone: make(chan struct{})}
}
func (supervisor *Supervisor) Start() error {
if supervisor.spec.Name == "" || supervisor.spec.Path == "" || supervisor.spec.MaxLogBytes < 1 || supervisor.spec.TerminateTimeout <= 0 || supervisor.spec.KillTimeout <= 0 {
return errors.New("invalid process specification")
}
command := exec.Command(supervisor.spec.Path, supervisor.spec.Args...)
command.Dir = supervisor.spec.Dir
command.Env = supervisor.spec.Env
if command.Env == nil {
command.Env = os.Environ()
}
command.ExtraFiles = supervisor.spec.ExtraFiles
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL, Credential: supervisor.spec.Credential}
supervisor.stdoutLog = newBoundedLog(supervisor.spec.Stdout, supervisor.spec.MaxLogBytes, supervisor.lineObserver(Stdout))
supervisor.stderrLog = newBoundedLog(supervisor.spec.Stderr, supervisor.spec.MaxLogBytes, supervisor.lineObserver(Stderr))
command.Stdout = supervisor.stdoutLog
command.Stderr = supervisor.stderrLog
if err := command.Start(); err != nil {
supervisor.closeExtraFiles()
return fmt.Errorf("start %s: %w", supervisor.spec.Name, err)
}
supervisor.closeExtraFiles()
supervisor.cmd = command
supervisor.pid = command.Process.Pid
supervisor.pgid = command.Process.Pid
go supervisor.reap()
go supervisor.watchContext()
return nil
}
func (supervisor *Supervisor) lineObserver(stream Stream) func(string) {
return func(line string) {
if supervisor.spec.Readiness != nil && supervisor.spec.Readiness(stream, line) {
supervisor.SignalReady()
}
}
}
func (supervisor *Supervisor) closeExtraFiles() {
for _, file := range supervisor.spec.ExtraFiles {
if file != nil {
_ = file.Close()
}
}
}
func (supervisor *Supervisor) reap() {
err := supervisor.cmd.Wait()
supervisor.stdoutLog.Close()
supervisor.stderrLog.Close()
supervisor.waitMu.Lock()
supervisor.waitErr = err
supervisor.waitMu.Unlock()
close(supervisor.exited)
}
func (supervisor *Supervisor) watchContext() {
select {
case <-supervisor.ctx.Done():
_ = supervisor.Stop(context.WithoutCancel(supervisor.ctx))
case <-supervisor.exited:
}
}
func (supervisor *Supervisor) SignalReady() {
supervisor.readyOnce.Do(func() { close(supervisor.ready) })
}
func (supervisor *Supervisor) Ready() <-chan struct{} { return supervisor.ready }
func (supervisor *Supervisor) Exited() <-chan struct{} { return supervisor.exited }
func (supervisor *Supervisor) WaitReady(ctx context.Context) error {
select {
case <-supervisor.ready:
return nil
default:
}
select {
case <-supervisor.ready:
return nil
case <-supervisor.exited:
select {
case <-supervisor.ready:
return nil
default:
return errors.New("process exited before readiness")
}
case <-ctx.Done():
return ctx.Err()
}
}
func (supervisor *Supervisor) Wait(ctx context.Context) error {
select {
case <-supervisor.exited:
cleanupErr := supervisor.Stop(ctx)
supervisor.waitMu.Lock()
waitErr := supervisor.waitErr
supervisor.waitMu.Unlock()
return errors.Join(waitErr, cleanupErr)
case <-ctx.Done():
return errors.Join(ctx.Err(), supervisor.Stop(context.WithoutCancel(ctx)))
}
}
func (supervisor *Supervisor) Stop(ctx context.Context) error {
supervisor.cleanupOnce.Do(func() { go supervisor.cleanup() })
select {
case <-supervisor.cleanupDone:
return supervisor.cleanupResult()
case <-ctx.Done():
return ctx.Err()
}
}
func (supervisor *Supervisor) cleanup() {
defer close(supervisor.cleanupDone)
if supervisor.pgid < 1 {
return
}
if !processGroupExists(supervisor.pgid) {
supervisor.waitForExit()
return
}
if err := syscall.Kill(-supervisor.pgid, syscall.SIGTERM); err != nil && !errors.Is(err, syscall.ESRCH) {
supervisor.setCleanupError(fmt.Errorf("terminate %s process group: %w", supervisor.spec.Name, err))
return
}
if waitProcessGroup(supervisor.pgid, supervisor.spec.TerminateTimeout) {
supervisor.waitForExit()
return
}
supervisor.stateMu.Lock()
supervisor.forced = true
supervisor.stateMu.Unlock()
if err := syscall.Kill(-supervisor.pgid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
supervisor.setCleanupError(fmt.Errorf("kill %s process group: %w", supervisor.spec.Name, err))
return
}
if !waitProcessGroup(supervisor.pgid, supervisor.spec.KillTimeout) {
supervisor.setCleanupError(fmt.Errorf("%s process group %d survived SIGKILL", supervisor.spec.Name, supervisor.pgid))
return
}
supervisor.waitForExit()
}
func (supervisor *Supervisor) waitForExit() {
timer := time.NewTimer(supervisor.spec.KillTimeout)
defer timer.Stop()
select {
case <-supervisor.exited:
case <-timer.C:
supervisor.setCleanupError(fmt.Errorf("%s process was not reaped", supervisor.spec.Name))
}
}
func (supervisor *Supervisor) setCleanupError(err error) {
supervisor.stateMu.Lock()
supervisor.cleanupErr = errors.Join(supervisor.cleanupErr, err)
supervisor.stateMu.Unlock()
}
func (supervisor *Supervisor) cleanupResult() error {
supervisor.stateMu.Lock()
defer supervisor.stateMu.Unlock()
return supervisor.cleanupErr
}
func processGroupExists(pgid int) bool {
err := syscall.Kill(-pgid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
func waitProcessGroup(pgid int, timeout time.Duration) bool {
if !processGroupExists(pgid) {
return true
}
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
timer := time.NewTimer(timeout)
defer timer.Stop()
for {
select {
case <-ticker.C:
if !processGroupExists(pgid) {
return true
}
case <-timer.C:
return !processGroupExists(pgid)
}
}
}
func (supervisor *Supervisor) PID() int { return supervisor.pid }
func (supervisor *Supervisor) ProcessGroupID() int { return supervisor.pgid }
func (supervisor *Supervisor) ForcedCleanup() bool {
supervisor.stateMu.Lock()
defer supervisor.stateMu.Unlock()
return supervisor.forced
}
func (supervisor *Supervisor) CleanupRecord() CleanupRecord {
supervisor.stateMu.Lock()
defer supervisor.stateMu.Unlock()
return CleanupRecord{Name: supervisor.spec.Name, PID: supervisor.pid, Forced: supervisor.forced, Error: errorString(supervisor.cleanupErr)}
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
@@ -0,0 +1,7 @@
//go:build linux && agentcompat
package process
func (supervisor *Supervisor) CleanupDoneForTest() <-chan struct{} {
return supervisor.cleanupDone
}
@@ -0,0 +1,195 @@
//go:build linux
package process
import (
"bufio"
"bytes"
"context"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func TestSupervisor_CleanExit(t *testing.T) {
// Given
supervisor := newHelperSupervisor(t.Context(), "clean", nil)
// When
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
// Then
requireNoError(t, supervisor.Wait(t.Context()))
}
func TestSupervisor_RunsChildWithConfiguredCredential(t *testing.T) {
// Given
credentialDirectory, err := os.MkdirTemp("/tmp", "agentcompat-credential-")
requireNoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(credentialDirectory) })
requireNoError(t, os.Chmod(credentialDirectory, 0o777))
marker := filepath.Join(credentialDirectory, "credential.txt")
supervisor := newHelperSupervisor(t.Context(), "credential", []string{helperMarkerEnv + "=" + marker})
testBinary, err := os.ReadFile(os.Args[0])
requireNoError(t, err)
executablePath := filepath.Join(credentialDirectory, "process-helper")
requireNoError(t, os.WriteFile(executablePath, testBinary, 0o755))
supervisor.spec.Path = executablePath
supervisor.spec.Credential = &syscall.Credential{Uid: 65534, Gid: 65534}
// When
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.Wait(t.Context()))
// Then
content, err := os.ReadFile(marker)
requireNoError(t, err)
if strings.TrimSpace(string(content)) != "65534:65534" {
t.Fatalf("child credential = %q, want 65534:65534", content)
}
}
func TestSupervisor_KillsProcessTree(t *testing.T) {
// Given
marker := filepath.Join(t.TempDir(), "grandchild.pid")
ctx, cancel := context.WithCancel(t.Context())
supervisor := newHelperSupervisor(ctx, "tree", []string{helperMarkerEnv + "=" + marker})
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
grandchildPID := readPID(t, marker)
processGroupID := supervisor.ProcessGroupID()
// When
cancel()
select {
case <-supervisor.cleanupDone:
case <-time.After(2 * time.Second):
t.Fatal("context cancellation did not complete process-tree cleanup")
}
// Then
requirePIDGone(t, supervisor.PID())
requirePIDGone(t, grandchildPID)
if err := syscall.Kill(-processGroupID, 0); !errors.Is(err, syscall.ESRCH) {
t.Fatalf("process group %d remains: %v", processGroupID, err)
}
}
func TestSupervisor_AdoptsListener(t *testing.T) {
// Given
listener, err := net.Listen("tcp4", "127.0.0.1:0")
requireNoError(t, err)
tcpListener := listener.(*net.TCPListener)
inheritedFile, err := tcpListener.File()
requireNoError(t, err)
requireNoError(t, tcpListener.Close())
supervisor := newHelperSupervisor(t.Context(), "listener", []string{helperFDEnv + "=3"})
supervisor.spec.ExtraFiles = []*os.File{inheritedFile}
// When
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
sample, err := SampleProcess(supervisor.PID())
requireNoError(t, err)
// Then
if sample.TCPListenerCount != 1 {
t.Fatalf("TCP listeners = %d, want 1", sample.TCPListenerCount)
}
requireNoError(t, supervisor.Stop(t.Context()))
requirePIDGone(t, supervisor.PID())
}
func TestSupervisor_RedactsLogs(t *testing.T) {
// Given
var output bytes.Buffer
supervisor := newHelperSupervisor(t.Context(), "logs", nil)
supervisor.spec.MaxLogBytes = 128
supervisor.spec.Stdout = &output
supervisor.spec.Stderr = &output
// When
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
requireNoError(t, supervisor.Wait(t.Context()))
// Then
logged := output.String()
if strings.Contains(logged, "top-secret") || strings.Contains(logged, "eyJsecret") {
t.Fatalf("secret survived supervisor log redaction: %s", logged)
}
if output.Len() > supervisor.spec.MaxLogBytes*2 {
t.Fatalf("combined log bytes = %d, per-stream limit = %d", output.Len(), supervisor.spec.MaxLogBytes)
}
if !strings.Contains(logged, truncationMarker) {
t.Fatalf("truncation marker missing: %q", logged)
}
}
func TestSupervisor_RecordsForcedCleanupForSIGTERMIgnoringChild(t *testing.T) {
// Given
resultsDir := t.TempDir()
marker := filepath.Join(t.TempDir(), "forced-grandchild.pid")
supervisor := newHelperSupervisor(t.Context(), "force-tree", []string{helperMarkerEnv + "=" + marker})
requireNoError(t, supervisor.Start())
requireNoError(t, supervisor.WaitReady(t.Context()))
grandchildPID := readPID(t, marker)
// When
requireNoError(t, supervisor.Stop(t.Context()))
receipt := NewCleanupReceipt([]CleanupRecord{supervisor.CleanupRecord()})
receiptPath := filepath.Join(resultsDir, "cleanup.json")
requireNoError(t, WriteCleanupReceipt(receiptPath, receipt))
// Then
if !supervisor.ForcedCleanup() {
t.Fatal("forced cleanup was not recorded")
}
data, err := os.ReadFile(receiptPath)
requireNoError(t, err)
if !strings.Contains(string(data), `"forced": true`) {
t.Fatalf("cleanup receipt = %s", data)
}
requirePIDGone(t, supervisor.PID())
requirePIDGone(t, grandchildPID)
}
func TestSupervisor_InterruptSignalCleansProcessTree(t *testing.T) {
// Given
marker := filepath.Join(t.TempDir(), "interrupt-grandchild.pid")
command := exec.Command(os.Args[0], "-test.run=^TestProcessHelper$")
command.Env = append(os.Environ(), helperModeEnv+"=interrupt-probe", helperMarkerEnv+"="+marker)
output, err := command.StdoutPipe()
requireNoError(t, err)
command.Stderr = os.Stderr
requireNoError(t, command.Start())
scanner := bufio.NewScanner(output)
ready := false
for scanner.Scan() {
if scanner.Text() == "PROBE_READY" {
ready = true
break
}
}
requireNoError(t, scanner.Err())
if !ready {
t.Fatal("interrupt probe exited before readiness")
}
leaderPID := readPID(t, marker+".leader")
grandchildPID := readPID(t, marker)
// When
requireNoError(t, command.Process.Signal(syscall.SIGTERM))
requireNoError(t, command.Wait())
// Then
requirePIDGone(t, leaderPID)
requirePIDGone(t, grandchildPID)
}