diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b09ed1f..1d776fd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1558,6 +1558,30 @@ jobs: echo "$store/bin" >>"$GITHUB_PATH" done + - name: Stack supervision unit tests (native darwin) + # The ONLY lane that EXECUTES the darwin process start-time identity + # reader. That reader is what lets `compass-stack up` run on macOS at + # all, and it is a two-site swap: the core records a token at spawn and + # the teardown adapter reads one back independently, with + # GroupSignaller.Alive comparing the two for uint64 equality. If the two + # darwin encodings ever drift, `down` matches nothing and SILENTLY skips + # every live child — no error, no signal, just an orphaned stack. Cross + # compiling from ubuntu type-checks those readers but never runs them, + # and the sysctl they call has no Linux equivalent to stand in, so this + # step is the only thing standing between a drifted encoding and a green + # PR. The suite is untagged: internal/stack is `//go:build unix`, which + # darwin satisfies, so a bare `go test` selects the _darwin.go readers + # and their mirrored packing tests with no `-tags` flag. + # + # It runs BEFORE the compile+bundle gate so an identity-reader + # regression reds fast rather than after the ~minutes-long bundle wrap. + # Deliberately NOT affected-guarded like that gate: this is seconds of + # pure-Go test on a runner the job already paid to boot, and the guard's + # own path list is the thing most likely to go stale. + env: + CGO_ENABLED: '0' + run: go -C go test -count=1 ./internal/stack/... + - name: macOS compile + bundle gate # The ONE CI lane that compiles the native shell on darwin + exercises # the macos-bundle tool end to end (compass-distribution T3). It is a diff --git a/go/go.mod b/go/go.mod index 8245db14..9af91517 100644 --- a/go/go.mod +++ b/go/go.mod @@ -55,6 +55,8 @@ require ( google.golang.org/protobuf v1.36.12 ) +require golang.org/x/sys v0.47.0 + require ( github.com/adrg/xdg v0.5.3 // indirect github.com/antithesishq/antithesis-sdk-go v0.7.2-default-no-op // indirect @@ -108,7 +110,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect diff --git a/go/internal/stack/adapters/groupsignal.go b/go/internal/stack/adapters/groupsignal.go index 115b4bc2..b65132c5 100644 --- a/go/internal/stack/adapters/groupsignal.go +++ b/go/internal/stack/adapters/groupsignal.go @@ -5,7 +5,6 @@ package adapters import ( "errors" "fmt" - "os" "strconv" "strings" "syscall" @@ -64,10 +63,10 @@ func (g *GroupSignaller) Signal(pgid int, sig stack.ProcessSignal) error { // gone-or-recycled group as if it were the original child. // // The two checks are ordered existence-then-identity: the kill(0) probe cheaply -// rules out the ESRCH case, then the /proc start-time read confirms the leader -// is the same process. A start-time read failure (the leader vanished between -// the two syscalls, or /proc is unavailable) is treated as not-alive — the safe -// verdict is never to signal. +// rules out the ESRCH case, then the start-time read confirms the leader is the +// same process. A start-time read failure (the leader vanished between the two +// syscalls, or the kernel's process table is unreadable) is treated as +// not-alive — the safe verdict is never to signal. func (g *GroupSignaller) Alive(pgid int, startTime uint64) bool { // A degenerate pgid is never a live compass child: kill(-1, 0) probes the // whole session and kill(0, 0) the caller's own group, both of which would @@ -90,27 +89,12 @@ func (g *GroupSignaller) Alive(pgid int, startTime uint64) bool { return got == startTime } -// readGroupLeaderStartTime reads field 22 (starttime) of /proc//stat — the -// group leader, since pid == pgid for a Setpgid child. It duplicates the core's -// parser (rather than exporting it across the package boundary) because the -// parenthesized-comm gotcha is the same on both sides and the two are read-only -// leaf helpers; see stack.parseStatStartTime for the full explanation. -func readGroupLeaderStartTime(pgid int) (uint64, error) { - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pgid)) - if err != nil { - return 0, fmt.Errorf("read /proc/%d/stat: %w", pgid, err) - } - startTime, err := parseGroupLeaderStat(string(data)) - if err != nil { - return 0, fmt.Errorf("/proc/%d/stat: %w", pgid, err) - } - return startTime, nil -} - // parseGroupLeaderStat extracts field 22 (starttime) from a /proc//stat -// line. Split out from readGroupLeaderStartTime so the parenthesized-comm parse -// is unit-tested against synthesized lines without a live process — the same -// split (and the same gotcha) as stack.parseStatStartTime. +// line. Split out from the Linux reader (groupsignal_linux.go) so the +// parenthesized-comm parse is unit-tested against synthesized lines without a +// live process — the same split (and the same gotcha) as +// stack.parseStatStartTime. It stays in the unix-built file so that test +// compiles on every unix: the parse rule is pure text handling. func parseGroupLeaderStat(line string) (uint64, error) { // comm (field 2) is parenthesized and may contain spaces AND parens, so // count fields from the LAST ')'; field[0] after it is state (field 3), so diff --git a/go/internal/stack/adapters/groupsignal_darwin.go b/go/internal/stack/adapters/groupsignal_darwin.go new file mode 100644 index 00000000..96a8606f --- /dev/null +++ b/go/internal/stack/adapters/groupsignal_darwin.go @@ -0,0 +1,53 @@ +//go:build darwin + +package adapters + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// readGroupLeaderStartTime reads the group leader's start time from the +// kernel's process table — the darwin half of the teardown-side identity read. +// There is no /proc on darwin, so the token comes from sysctl +// kern.proc.pid., whose KinfoProc carries the process's creation timeval +// (Proc.P_starttime). The leader's pid is the pgid, since pid == pgid for a +// Setpgid child. +// +// A pgid that names no process yields an error rather than a zero token: the +// kernel returns a short result for an unknown pid, which SysctlKinfoProc +// rejects, and the explicit zero-timeval guard closes the remaining case. Alive +// then reports not-alive, so the identity check fails closed. +func readGroupLeaderStartTime(pgid int) (uint64, error) { + kp, err := unix.SysctlKinfoProc("kern.proc.pid", pgid) + if err != nil { + return 0, fmt.Errorf("sysctl kern.proc.pid.%d: %w", pgid, err) + } + tv := kp.Proc.P_starttime + // Sec alone is the guard, matching the spawn side (stack.readProcessStartTime): + // Sec is signed, so a negative would pack into a huge uint64 that looks like + // a valid token, while Usec == 0 is a legitimate exact-second start and must + // not be rejected. + if tv.Sec <= 0 { + return 0, fmt.Errorf("sysctl kern.proc.pid.%d: no usable start timeval (sec=%d usec=%d)", + pgid, tv.Sec, tv.Usec) + } + return packGroupLeaderTimeval(tv), nil +} + +// packGroupLeaderTimeval flattens a process-creation timeval into the uint64 +// identity token the pgid record carries. +// +// It MUST stay byte-identical in effect to stack.packStartTimeval, which the +// spawn side uses to write the token this reads back. The two packages cannot +// import each other's internals, so the expression is duplicated for the same +// reason parseGroupLeaderStat duplicates stack.parseStatStartTime — and here the +// duplication is the load-bearing one: Alive compares this against a token the +// spawn side produced, for uint64 equality, so any drift would report every live +// child as not-alive and silently skip it at teardown. The mirror test +// (groupsignal_darwin_test.go) feeds one synthetic timeval through both packings +// and asserts the same uint64, so a one-sided change reds. +func packGroupLeaderTimeval(tv unix.Timeval) uint64 { + return uint64(tv.Sec)*1_000_000 + uint64(tv.Usec) +} diff --git a/go/internal/stack/adapters/groupsignal_darwin_test.go b/go/internal/stack/adapters/groupsignal_darwin_test.go new file mode 100644 index 00000000..ef05863e --- /dev/null +++ b/go/internal/stack/adapters/groupsignal_darwin_test.go @@ -0,0 +1,61 @@ +//go:build darwin + +package adapters + +import ( + "testing" + + "golang.org/x/sys/unix" +) + +// TestPackGroupLeaderTimevalMatchesSpawnSide is the down half of the mirror-test +// pair that pins the darwin identity encoding, exactly as +// TestParseGroupLeaderStatParsesParenthesizedComm mirrors the Linux parser. It +// feeds the SAME synthetic timeval the spawn-side test +// (stack.TestPackStartTimevalMatchesDownSide) uses and asserts the same literal +// uint64. +// +// The duplication it guards is load-bearing: Alive compares this packing's +// output against a token stack.packStartTimeval produced at spawn, for uint64 +// equality, so a one-sided change to either expression would report every live +// child as not-alive and silently skip it at teardown — the worst teardown +// failure available, because it is silent. +func TestPackGroupLeaderTimevalMatchesSpawnSide(t *testing.T) { + tv := unix.Timeval{Sec: 1_700_000_123, Usec: 456_789} + const want = uint64(1_700_000_123)*1_000_000 + 456_789 + if got := packGroupLeaderTimeval(tv); got != want { + t.Fatalf("packGroupLeaderTimeval(%d.%06d) = %d, want %d", tv.Sec, tv.Usec, got, want) + } +} + +// TestReadGroupLeaderStartTimeSelfIsStable drives the real darwin sysctl reader +// against a live process (this one, its own group leader candidate): the token +// must be non-zero and identical across two reads, or the identity gate would +// stop matching a group moments after it was recorded. +func TestReadGroupLeaderStartTimeSelfIsStable(t *testing.T) { + pid := unix.Getpid() + first, err := readGroupLeaderStartTime(pid) + if err != nil { + t.Fatalf("readGroupLeaderStartTime(%d) = %v", pid, err) + } + if first == 0 { + t.Fatalf("readGroupLeaderStartTime(%d) = 0, want a non-zero identity token", pid) + } + second, err := readGroupLeaderStartTime(pid) + if err != nil { + t.Fatalf("readGroupLeaderStartTime(%d) second read = %v", pid, err) + } + if first != second { + t.Fatalf("start time not stable across reads: %d then %d", first, second) + } +} + +// TestReadGroupLeaderStartTimeDeadPGIDErrors proves the reader fails closed for +// a pgid that names no process, so Alive reports not-alive rather than matching +// on a zero token. +func TestReadGroupLeaderStartTimeDeadPGIDErrors(t *testing.T) { + dead := deadPGID(t) + if got, err := readGroupLeaderStartTime(dead); err == nil { + t.Fatalf("readGroupLeaderStartTime(%d) = %d, nil for a dead pgid; want an error", dead, got) + } +} diff --git a/go/internal/stack/adapters/groupsignal_linux.go b/go/internal/stack/adapters/groupsignal_linux.go new file mode 100644 index 00000000..94dff9b6 --- /dev/null +++ b/go/internal/stack/adapters/groupsignal_linux.go @@ -0,0 +1,27 @@ +//go:build linux + +package adapters + +import ( + "fmt" + "os" +) + +// readGroupLeaderStartTime reads field 22 (starttime, in clock ticks since +// boot) of /proc//stat — the Linux half of the teardown-side identity +// read. The parenthesized-comm parse rule lives in parseGroupLeaderStat +// (groupsignal.go) so it is unit-testable without a live process. +// +// A pgid that names no process has no /proc entry, so the read fails and Alive +// reports not-alive — the identity check fails closed and never signals. +func readGroupLeaderStartTime(pgid int) (uint64, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pgid)) + if err != nil { + return 0, fmt.Errorf("read /proc/%d/stat: %w", pgid, err) + } + startTime, err := parseGroupLeaderStat(string(data)) + if err != nil { + return 0, fmt.Errorf("/proc/%d/stat: %w", pgid, err) + } + return startTime, nil +} diff --git a/go/internal/stack/adapters/groupsignal_other.go b/go/internal/stack/adapters/groupsignal_other.go new file mode 100644 index 00000000..8cfaab5e --- /dev/null +++ b/go/internal/stack/adapters/groupsignal_other.go @@ -0,0 +1,23 @@ +//go:build unix && !linux && !darwin + +package adapters + +import ( + "fmt" + "runtime" +) + +// readGroupLeaderStartTime refuses on a unix that is neither linux nor darwin, +// for the same reason as the spawn-side reader (stack.readProcessStartTime): +// groupsignal.go is //go:build unix, so this symbol must exist under every +// build constraint the package accepts. +// +// Refusing is fail-closed here too. Alive treats a read error as not-alive, so +// an unsupported host reports no live group rather than claiming one — the safe +// direction, since the alternative is signalling a pid the token cannot vouch +// for. +func readGroupLeaderStartTime(pgid int) (uint64, error) { + return 0, fmt.Errorf( + "reading the start-time identity token for process group %d is not implemented on %s "+ + "(the supervised stack runs on linux and darwin)", pgid, runtime.GOOS) +} diff --git a/go/internal/stack/adapters/groupsignal_test.go b/go/internal/stack/adapters/groupsignal_test.go index b13adc95..402fbd51 100644 --- a/go/internal/stack/adapters/groupsignal_test.go +++ b/go/internal/stack/adapters/groupsignal_test.go @@ -78,10 +78,11 @@ func TestGroupSignallerUnknownSignal(t *testing.T) { // of the /proc//stat field-22 parse against the parenthesized-comm gotcha: // a comm with embedded spaces AND parens must not throw off the field count. It // mirrors stack.TestReadStartTimeProcParsesParenthesizedComm so the two parsers -// (deliberately duplicated, groupsignal.go:93-97) cannot drift on the +// (deliberately duplicated, see readGroupLeaderStartTime) cannot drift on the // load-bearing identity token — a "simplify to strings.Fields(line)" regression // here would be caught rather than only by the real-/proc integration test whose -// comm has no embedded spaces. +// comm has no embedded spaces. The darwin encoding has its own mirrored pair, +// TestPackGroupLeaderTimevalMatchesSpawnSide, for the same reason. func TestParseGroupLeaderStatParsesParenthesizedComm(t *testing.T) { // comm is "(weird )(name)" — embedded spaces and parens; starttime (field 22) // is 987654. diff --git a/go/internal/stack/pgidfile.go b/go/internal/stack/pgidfile.go index db65e00a..0795d3b3 100644 --- a/go/internal/stack/pgidfile.go +++ b/go/internal/stack/pgidfile.go @@ -331,40 +331,30 @@ func removePgidFile(stateDir string) error { } // readStartTime is the package-internal seam that reads a process's start time -// (the identity token). It is a var, not a func, so tests can stub it without a -// live process. The wired implementation reads /proc//stat, which exists -// only on Linux — and the embedded stack is Linux/podman-only at runtime anyway -// (the runner loop, compass-native-app design.md:247,346-348), so the seam is a -// test seam, not a cross-OS portability claim: on a non-Linux unix this reader -// fails and up refuses, which is the correct outcome on an unsupported host. -var readStartTime = readStartTimeProc - -// readStartTimeProc reads field 22 (starttime, in clock ticks since boot) of -// /proc//stat. +// — the identity token that closes the pid-recycling window. It is a var, not a +// func, so tests can stub it without a live process. // -// The parse gotcha: field 2 (comm) is the executable name wrapped in -// parentheses and MAY itself contain spaces AND parentheses (e.g. a process -// named "(ec) foo"), so splitting the whole line on whitespace miscounts. The -// robust parse the kernel documents (proc(5)) is to find the LAST ')' — comm is -// the only parenthesized field and everything after it is space-separated -// fixed-position fields — then count fields from there. After the last ')': -// field[0] is state (field 3), so starttime (field 22) is field[22-3] = index -// 19 of the post-comm split. -func readStartTimeProc(pid int) (uint64, error) { - data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if err != nil { - return 0, fmt.Errorf("read /proc/%d/stat: %w", pid, err) - } - startTime, err := parseStatStartTime(string(data)) - if err != nil { - return 0, fmt.Errorf("/proc/%d/stat: %w", pid, err) - } - return startTime, nil -} +// It is BOTH a test seam and a cross-OS seam. The wired implementation is +// per-OS (readstarttime_linux.go reads /proc//stat, readstarttime_darwin.go +// reads the KinfoProc start timeval via sysctl), and each OS's encoding is its +// own: Linux clock-ticks-since-boot and darwin microseconds-since-epoch are +// never compared against each other, because a token is written and read on one +// host. +// +// The invariant that IS load-bearing: this spawn-side reader and the down-side +// reader (adapters.readGroupLeaderStartTime) must produce the IDENTICAL encoding +// on a given OS. GroupSignaller.Alive compares the two for uint64 equality, so a +// disagreement would report every live child as not-alive and silently skip it +// at teardown. The two darwin readers therefore share one packing rule +// (sec*1e6 + usec), pinned by mirrored unit tests in both packages. +var readStartTime = readProcessStartTime // parseStatStartTime extracts field 22 (starttime) from a /proc//stat line. -// Split out from readStartTimeProc so the parenthesized-comm parse is unit-tested -// against synthesized lines without a live process. +// Split out from the Linux reader (readstarttime_linux.go) so the +// parenthesized-comm parse is unit-tested against synthesized lines without a +// live process. It stays in the unix-built file, not the _linux one, so that +// test compiles and runs on every unix — the parse rule is pure text handling +// with no /proc dependency of its own. func parseStatStartTime(line string) (uint64, error) { rparen := strings.LastIndexByte(line, ')') if rparen < 0 { diff --git a/go/internal/stack/readstarttime_darwin.go b/go/internal/stack/readstarttime_darwin.go new file mode 100644 index 00000000..68ca2e2a --- /dev/null +++ b/go/internal/stack/readstarttime_darwin.go @@ -0,0 +1,58 @@ +//go:build darwin + +package stack + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// readProcessStartTime reads the process's start time from the kernel's process +// table — the darwin half of the readStartTime seam. There is no /proc on +// darwin, so the token comes from sysctl kern.proc.pid., whose KinfoProc +// carries the process's creation timeval (Proc.P_starttime). +// +// A pid that does not exist yields an error rather than a zero token: the +// kernel returns a short (zero-length) result for an unknown pid, which +// SysctlKinfoProc rejects, and the explicit zero-timeval guard below closes the +// remaining case. This matters because the token feeds an equality check — a +// reader that quietly returned 0 for a dead pid would match any record that +// happened to carry 0, so the read must fail closed. +func readProcessStartTime(pid int) (uint64, error) { + kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil { + return 0, fmt.Errorf("sysctl kern.proc.pid.%d: %w", pid, err) + } + tv := kp.Proc.P_starttime + // Sec alone is the guard. A real process start time is a wall-clock epoch + // second, so any value <= 0 means the kernel gave us nothing usable — and + // Sec is signed, so a negative would otherwise pack into a huge uint64 that + // looks like a valid token. Usec is deliberately NOT part of the condition: + // a process really can start on an exact second boundary, and rejecting + // Usec == 0 would fail a legitimate read about once in a million. + if tv.Sec <= 0 { + return 0, fmt.Errorf("sysctl kern.proc.pid.%d: no usable start timeval (sec=%d usec=%d)", + pid, tv.Sec, tv.Usec) + } + return packStartTimeval(tv), nil +} + +// packStartTimeval flattens a process-creation timeval into the uint64 identity +// token the pgid record carries. +// +// This expression is DUPLICATED in adapters.packGroupLeaderTimeval, which the +// teardown side uses, for the same reason the /proc field-22 parse is +// duplicated there: the two are read-only leaf helpers and the packages cannot +// reach into each other. The duplication is load-bearing rather than incidental +// — GroupSignaller.Alive compares a spawn-side token against a down-side read +// for uint64 equality, so a drift between the two packings would report every +// live child as not-alive and silently skip it at teardown. Mirrored tests in +// both packages feed one synthetic timeval through both and assert the same +// uint64, so a change to one packing without the other reds. +// +// Microseconds since the epoch fits a uint64 for ~584,000 years, so the +// multiply cannot overflow for any real process. +func packStartTimeval(tv unix.Timeval) uint64 { + return uint64(tv.Sec)*1_000_000 + uint64(tv.Usec) +} diff --git a/go/internal/stack/readstarttime_darwin_test.go b/go/internal/stack/readstarttime_darwin_test.go new file mode 100644 index 00000000..bdcc09d2 --- /dev/null +++ b/go/internal/stack/readstarttime_darwin_test.go @@ -0,0 +1,64 @@ +//go:build darwin + +package stack + +import ( + "os" + "testing" + + "golang.org/x/sys/unix" +) + +// TestPackStartTimevalMatchesDownSide is the spawn half of the mirror-test pair +// that pins the darwin identity encoding. The down side +// (adapters.TestPackGroupLeaderTimevalMatchesSpawnSide) feeds the SAME synthetic +// timeval through its own packing and asserts this same literal. +// +// It exists because the two packings are deliberately duplicated across a +// package boundary the packages cannot cross, and GroupSignaller.Alive compares +// their outputs for uint64 equality — a drift would report every live child as +// not-alive and silently skip it at teardown. Both tests must be updated +// together or one reds, which is the point. +func TestPackStartTimevalMatchesDownSide(t *testing.T) { + tv := unix.Timeval{Sec: 1_700_000_123, Usec: 456_789} + const want = uint64(1_700_000_123)*1_000_000 + 456_789 + if got := packStartTimeval(tv); got != want { + t.Fatalf("packStartTimeval(%d.%06d) = %d, want %d", tv.Sec, tv.Usec, got, want) + } +} + +// TestReadProcessStartTimeSelfIsStable drives the real darwin sysctl reader +// against a live process (this one): the token must be non-zero and identical +// across two reads. A start time that moved between reads, or came back zero, +// would break the identity gate — Alive would stop matching a group it spawned +// moments earlier and skip it at teardown. +func TestReadProcessStartTimeSelfIsStable(t *testing.T) { + pid := os.Getpid() + first, err := readProcessStartTime(pid) + if err != nil { + t.Fatalf("readProcessStartTime(%d) = %v", pid, err) + } + if first == 0 { + t.Fatalf("readProcessStartTime(%d) = 0, want a non-zero identity token", pid) + } + second, err := readProcessStartTime(pid) + if err != nil { + t.Fatalf("readProcessStartTime(%d) second read = %v", pid, err) + } + if first != second { + t.Fatalf("start time not stable across reads: %d then %d", first, second) + } +} + +// TestReadProcessStartTimeDeadPidErrors proves the reader fails closed for a pid +// that names no process. It must NOT return a zero token: the token feeds an +// equality check, so a silent 0 would match any record carrying 0 and signal a +// group that is not ours. The dead pid comes from the package's existing +// deadPID scan (lockfile_test.go), not a guessed constant a busy host could +// have live. +func TestReadProcessStartTimeDeadPidErrors(t *testing.T) { + dead := deadPID(t) + if got, err := readProcessStartTime(dead); err == nil { + t.Fatalf("readProcessStartTime(%d) = %d, nil for a dead pid; want an error", dead, got) + } +} diff --git a/go/internal/stack/readstarttime_linux.go b/go/internal/stack/readstarttime_linux.go new file mode 100644 index 00000000..4a532adc --- /dev/null +++ b/go/internal/stack/readstarttime_linux.go @@ -0,0 +1,35 @@ +//go:build linux + +package stack + +import ( + "fmt" + "os" +) + +// readProcessStartTime reads field 22 (starttime, in clock ticks since boot) of +// /proc//stat — the Linux half of the readStartTime seam. +// +// The parse gotcha: field 2 (comm) is the executable name wrapped in +// parentheses and MAY itself contain spaces AND parentheses (e.g. a process +// named "(ec) foo"), so splitting the whole line on whitespace miscounts. The +// robust parse the kernel documents (proc(5)) is to find the LAST ')' — comm is +// the only parenthesized field and everything after it is space-separated +// fixed-position fields — then count fields from there. After the last ')': +// field[0] is state (field 3), so starttime (field 22) is field[22-3] = index +// 19 of the post-comm split. That rule lives in parseStatStartTime (pgidfile.go) +// so it is unit-testable without a live process. +// +// A pid that does not exist has no /proc entry, so the read fails and the +// identity check fails closed — it never matches a recorded token. +func readProcessStartTime(pid int) (uint64, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0, fmt.Errorf("read /proc/%d/stat: %w", pid, err) + } + startTime, err := parseStatStartTime(string(data)) + if err != nil { + return 0, fmt.Errorf("/proc/%d/stat: %w", pid, err) + } + return startTime, nil +} diff --git a/go/internal/stack/readstarttime_other.go b/go/internal/stack/readstarttime_other.go new file mode 100644 index 00000000..ca30e5b5 --- /dev/null +++ b/go/internal/stack/readstarttime_other.go @@ -0,0 +1,27 @@ +//go:build unix && !linux && !darwin + +package stack + +import ( + "fmt" + "runtime" +) + +// readProcessStartTime refuses on a unix that is neither linux nor darwin. +// +// The seam must RESOLVE on every OS this package is built for: pgidfile.go is +// //go:build unix, so readProcessStartTime has to exist under every build +// constraint the package accepts or the package does not compile there at all. +// The BSDs and solaris satisfy `unix`, and each reads process start time its +// own way, so there is no reader here to share. +// +// A named refusal, not a build break: the supervised stack ships on linux and +// darwin, and a compile failure on an OS nobody targets is a worse outcome than +// a legible runtime error for anyone who tries. The refusal is also the +// fail-closed answer — an identity token that cannot be read must never come +// back as a value that might compare equal. +func readProcessStartTime(pid int) (uint64, error) { + return 0, fmt.Errorf( + "reading the start-time identity token for pid %d is not implemented on %s "+ + "(the supervised stack runs on linux and darwin)", pid, runtime.GOOS) +}