Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 9 additions & 25 deletions go/internal/stack/adapters/groupsignal.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package adapters
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
Expand Down Expand Up @@ -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
Expand All @@ -90,27 +89,12 @@ func (g *GroupSignaller) Alive(pgid int, startTime uint64) bool {
return got == startTime
}

// readGroupLeaderStartTime reads field 22 (starttime) of /proc/<pgid>/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/<pid>/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
Expand Down
53 changes: 53 additions & 0 deletions go/internal/stack/adapters/groupsignal_darwin.go
Original file line number Diff line number Diff line change
@@ -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.<pgid>, 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)
}
61 changes: 61 additions & 0 deletions go/internal/stack/adapters/groupsignal_darwin_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 27 additions & 0 deletions go/internal/stack/adapters/groupsignal_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//go:build linux

package adapters

import (
"fmt"
"os"
)

// readGroupLeaderStartTime reads field 22 (starttime, in clock ticks since
// boot) of /proc/<pgid>/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
}
23 changes: 23 additions & 0 deletions go/internal/stack/adapters/groupsignal_other.go
Original file line number Diff line number Diff line change
@@ -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)
}
5 changes: 3 additions & 2 deletions go/internal/stack/adapters/groupsignal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ func TestGroupSignallerUnknownSignal(t *testing.T) {
// of the /proc/<pid>/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.
Expand Down
52 changes: 21 additions & 31 deletions go/internal/stack/pgidfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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/<pid>/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/<pid>/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/<pid>/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 {
Expand Down
Loading
Loading