mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
test(agentcompat): add process supervision harness
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user