diff --git a/internal/cli/wake_lock.go b/internal/cli/wake_lock.go index 5ab417d..8974079 100644 --- a/internal/cli/wake_lock.go +++ b/internal/cli/wake_lock.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -33,6 +34,7 @@ type wakeProcessInfo struct { Running bool StartToken string BootID string + LegacyBootID string Executable string Args []string InspectError error @@ -200,7 +202,7 @@ func classifyWakeLock(root, me string, inspection *wakeLockInspection) { inspection.Reason = inspectionReason("process start time unavailable", proc.InspectError) return } - if lock.BootID != "" && proc.BootID != "" && lock.BootID != proc.BootID { + if compareWakeBootID(lock.BootID, proc) != bootIDMatch { inspection.Status = wakeLockUnverified if wakeProcessProvenNotWake(proc) { inspection.Status = wakeLockStale @@ -322,7 +324,7 @@ func currentWakeLockMatches(lock wakeLock) bool { if !proc.Running || proc.StartToken == "" { return false } - if lock.BootID != "" && proc.BootID != "" && lock.BootID != proc.BootID { + if compareWakeBootID(lock.BootID, proc) != bootIDMatch { return false } return lock.ProcessStart == proc.StartToken @@ -413,7 +415,7 @@ func wakeProcessStillMatches(inspection wakeLockInspection) bool { if proc.StartToken == "" || inspection.Lock.ProcessStart != proc.StartToken { return false } - if inspection.Lock.BootID != "" && proc.BootID != "" && inspection.Lock.BootID != proc.BootID { + if compareWakeBootID(inspection.Lock.BootID, proc) == bootIDMismatch { return false } } @@ -428,3 +430,93 @@ func wakeProcessStillMatches(inspection wakeLockInspection) bool { } return true } + +type bootIDComparison int + +const ( + bootIDMatch bootIDComparison = iota + bootIDMismatch + bootIDUnknown +) + +func compareWakeBootID(recorded string, proc wakeProcessInfo) bootIDComparison { + if recorded == "" { + return bootIDMatch + } + if proc.BootID == "" && proc.LegacyBootID == "" { + return bootIDUnknown + } + if recorded == proc.BootID || recorded == proc.LegacyBootID { + return bootIDMatch + } + for _, current := range []string{proc.BootID, proc.LegacyBootID} { + if legacyDarwinBootIDsMatch(recorded, current) { + return bootIDMatch + } + } + // Only unlike boots of the same identity representation are conclusive. + // A UUID cannot be disproved by a readable boottime, and vice versa. + if isDarwinBootUUID(recorded) && isDarwinBootUUID(proc.BootID) { + return bootIDMismatch + } + if _, ok := parseLegacyDarwinBootID(recorded); ok { + if _, ok := parseLegacyDarwinBootID(proc.LegacyBootID); ok { + return bootIDMismatch + } + } + return bootIDUnknown +} + +func wakeBootIDMismatch(recorded string, proc wakeProcessInfo) bool { + return compareWakeBootID(recorded, proc) == bootIDMismatch +} + +func isDarwinBootUUID(value string) bool { + if len(value) != 36 { + return false + } + for i, r := range value { + if i == 8 || i == 13 || i == 18 || i == 23 { + if r != '-' { + return false + } + continue + } + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} + +// Legacy Darwin boot IDs came from kern.boottime, which can move slightly as +// macOS corrects wall-clock time. A one-second migration tolerance preserves +// old live wake locks without making two realistically distinct boots equal. +func legacyDarwinBootIDsMatch(first, second string) bool { + firstTime, firstOK := parseLegacyDarwinBootID(first) + secondTime, secondOK := parseLegacyDarwinBootID(second) + if !firstOK || !secondOK { + return false + } + delta := firstTime.Sub(secondTime) + if delta < 0 { + delta = -delta + } + return delta <= time.Second +} + +func parseLegacyDarwinBootID(value string) (time.Time, bool) { + seconds, nanos, ok := strings.Cut(value, ".") + if !ok || seconds == "" || len(nanos) != 9 || strings.Contains(nanos, ".") { + return time.Time{}, false + } + sec, err := strconv.ParseInt(seconds, 10, 64) + if err != nil { + return time.Time{}, false + } + nsec, err := strconv.ParseInt(nanos, 10, 64) + if err != nil || nsec < 0 || nsec >= int64(time.Second) { + return time.Time{}, false + } + return time.Unix(sec, nsec), true +} diff --git a/internal/cli/wake_lock_boot_regression_test.go b/internal/cli/wake_lock_boot_regression_test.go new file mode 100644 index 0000000..a32ee5d --- /dev/null +++ b/internal/cli/wake_lock_boot_regression_test.go @@ -0,0 +1,23 @@ +package cli + +import "testing" + +func TestStillMatchesLiveWakeUncheckableBootIsStillPresent(t *testing.T) { + insp := wakeLockInspection{PID: 4343, Lock: wakeLock{PID: 4343, ProcessStart: "start-1", BootID: "recorded-boot", Executable: "/opt/homebrew/bin/amq"}, Root: "/tmp/x", Agent: "codex"} + stubInspectWakeProcess(t, func(pid int) wakeProcessInfo { + return wakeProcessInfo{PID: pid, Running: true, StartToken: "start-1", Executable: "/opt/homebrew/bin/amq", Args: []string{"/opt/homebrew/bin/amq", "wake", "--root", "/tmp/x", "--me", "codex"}} + }) + if !wakeProcessStillMatches(insp) { + t.Fatal("live wake with unknown boot identity must remain present") + } +} + +func TestStillMatchesDifferentUUIDBootIsGone(t *testing.T) { + insp := wakeLockInspection{PID: 4343, Lock: wakeLock{PID: 4343, ProcessStart: "start-1", BootID: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E", Executable: "/opt/homebrew/bin/amq"}, Root: "/tmp/x", Agent: "codex"} + stubInspectWakeProcess(t, func(pid int) wakeProcessInfo { + return wakeProcessInfo{PID: pid, Running: true, StartToken: "start-1", BootID: "AAAAAAAA-901B-4243-8B5C-287FAFB9AD0E", Executable: "/opt/homebrew/bin/amq", Args: []string{"/opt/homebrew/bin/amq", "wake", "--root", "/tmp/x", "--me", "codex"}} + }) + if wakeProcessStillMatches(insp) { + t.Fatal("different UUID boot must be gone") + } +} diff --git a/internal/cli/wake_lock_test.go b/internal/cli/wake_lock_test.go index 522b756..8b68bd4 100644 --- a/internal/cli/wake_lock_test.go +++ b/internal/cli/wake_lock_test.go @@ -86,3 +86,117 @@ func bindWakeLockToTarget(lock wakeLock, target wakeTarget) wakeLock { lock.TargetDigest = wakeTargetDigest(target) return lock } + +func TestWakeBootIDMismatchAcceptsDarwinLegacyMigration(t *testing.T) { + tests := []struct { + name string + recorded string + process wakeProcessInfo + mismatch bool + }{ + { + name: "current boot session uuid", + recorded: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E", + process: wakeProcessInfo{BootID: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E"}, + }, + { + name: "legacy boot time with macOS clock correction", + recorded: "1783327533.465308000", + process: wakeProcessInfo{ + BootID: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E", + LegacyBootID: "1783327533.407566000", + }, + }, + { + name: "boot time fallback with macOS clock correction", + recorded: "1783327533.465308000", + process: wakeProcessInfo{BootID: "1783327533.407566000"}, + }, + { + name: "different legacy boot", + recorded: "1783327533.465308000", + process: wakeProcessInfo{ + BootID: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E", + LegacyBootID: "1783327535.407566000", + }, + mismatch: true, + }, + { + name: "different boot session uuid", + recorded: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + process: wakeProcessInfo{BootID: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB"}, + mismatch: true, + }, + { + name: "recorded boot with unavailable current identity", + recorded: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + process: wakeProcessInfo{}, + mismatch: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := wakeBootIDMismatch(tc.recorded, tc.process); got != tc.mismatch { + t.Fatalf("wakeBootIDMismatch() = %v, want %v", got, tc.mismatch) + } + }) + } +} + +func TestInspectWakeLockAcceptsLegacyDarwinBootIDForProvenWake(t *testing.T) { + const wakePID = 4242 + root := secureTempDirForTest(t) + writeWakeLockForTest(t, root, "codex", wakeLock{ + PID: wakePID, + TTY: "tty", + ProcessStart: "start-1", + BootID: "1783327533.465308000", + Executable: "/opt/homebrew/bin/amq", + }) + stubInspectWakeProcess(t, func(pid int) wakeProcessInfo { + if pid == wakePID { + return wakeProcessInfo{ + PID: pid, + Running: true, + StartToken: "start-1", + BootID: "9C0682F4-901B-4243-8B5C-287FAFB9AD0E", + LegacyBootID: "1783327533.407566000", + Executable: "/opt/homebrew/bin/amq", + Args: []string{"/opt/homebrew/bin/amq", "wake", "--root", root, "--me", "codex"}, + } + } + return wakeProcessInfo{PID: pid} + }) + + inspection := inspectWakeLock(root, "codex") + if inspection.Status != wakeLockValid || !inspection.IdentityConfirmed { + t.Fatalf("inspection = status %q reason %q confirmed %v", inspection.Status, inspection.Reason, inspection.IdentityConfirmed) + } +} + +func TestInspectWakeLockTreatsUnavailableCurrentBootIdentityAsUnverified(t *testing.T) { + const wakePID = 4343 + root := secureTempDirForTest(t) + writeWakeLockForTest(t, root, "codex", wakeLock{ + PID: wakePID, + TTY: "tty", + ProcessStart: "start-1", + BootID: "recorded-boot", + Executable: "/opt/homebrew/bin/amq", + }) + stubInspectWakeProcess(t, func(pid int) wakeProcessInfo { + return wakeProcessInfo{ + PID: pid, + Running: true, + StartToken: "start-1", + Executable: "/opt/homebrew/bin/amq", + Args: []string{"/opt/homebrew/bin/amq", "wake", "--root", root, "--me", "codex"}, + } + }) + + inspection := inspectWakeLock(root, "codex") + if inspection.Status != wakeLockUnverified { + t.Fatalf("inspection status = %q, want unverified (reason %q)", inspection.Status, inspection.Reason) + } +} diff --git a/internal/cli/wake_process_darwin.go b/internal/cli/wake_process_darwin.go index de57fb6..c112f92 100644 --- a/internal/cli/wake_process_darwin.go +++ b/internal/cli/wake_process_darwin.go @@ -4,10 +4,25 @@ package cli import ( "fmt" + "strings" "golang.org/x/sys/unix" ) +// SZOMB from : the process has exited and is waiting for its +// parent to reap it. kill(pid, 0) still succeeds for this state. +const darwinProcessStateZombie int8 = 5 + +var ( + readDarwinKinfoProc = unix.SysctlKinfoProc + readDarwinBootSessionUUID = func() (string, error) { + return unix.Sysctl("kern.bootsessionuuid") + } + readDarwinBootTime = func() (*unix.Timeval, error) { + return unix.SysctlTimeval("kern.boottime") + } +) + func inspectWakeProcessPlatform(pid int) wakeProcessInfo { info := wakeProcessInfo{PID: pid} if !processAlive(pid) { @@ -15,7 +30,7 @@ func inspectWakeProcessPlatform(pid int) wakeProcessInfo { } info.Running = true - kp, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + kp, err := readDarwinKinfoProc("kern.proc.pid", pid) if err != nil { info.InspectError = err return info @@ -24,19 +39,38 @@ func inspectWakeProcessPlatform(pid int) wakeProcessInfo { info.Running = false return info } + if kp.Proc.P_stat == darwinProcessStateZombie { + info.Running = false + return info + } sec, nsec := kp.Proc.P_starttime.Unix() info.StartToken = fmt.Sprintf("%d.%09d", sec, nsec) info.Executable = nulTerminatedString(kp.Proc.P_comm[:]) - if boot, err := unix.SysctlTimeval("kern.boottime"); err == nil && boot != nil { - bsec, bnsec := boot.Unix() - info.BootID = fmt.Sprintf("%d.%09d", bsec, bnsec) - } + info.BootID, info.LegacyBootID = darwinBootIdentity() return info } +func darwinBootIdentity() (bootID, legacyBootID string) { + if boot, err := readDarwinBootTime(); err == nil && boot != nil { + sec, nsec := boot.Unix() + legacyBootID = fmt.Sprintf("%d.%09d", sec, nsec) + } + + if sessionUUID, err := readDarwinBootSessionUUID(); err == nil { + sessionUUID = strings.TrimSpace(sessionUUID) + if sessionUUID != "" && !strings.ContainsRune(sessionUUID, 0) { + return sessionUUID, legacyBootID + } + } + + // kern.boottime was AMQ's Darwin boot identity before v0.42. Keep it as a + // best-effort fallback for macOS versions where bootsessionuuid is absent. + return legacyBootID, "" +} + func nulTerminatedString(raw []byte) string { for i, b := range raw { if b == 0 { diff --git a/internal/cli/wake_process_darwin_test.go b/internal/cli/wake_process_darwin_test.go new file mode 100644 index 0000000..9f32c82 --- /dev/null +++ b/internal/cli/wake_process_darwin_test.go @@ -0,0 +1,87 @@ +//go:build darwin + +package cli + +import ( + "errors" + "os" + "testing" + + "golang.org/x/sys/unix" +) + +func TestInspectWakeProcessPlatformTreatsDarwinZombieAsNotRunning(t *testing.T) { + old := readDarwinKinfoProc + readDarwinKinfoProc = func(name string, args ...int) (*unix.KinfoProc, error) { + if name != "kern.proc.pid" || len(args) != 1 || args[0] != os.Getpid() { + t.Fatalf("unexpected sysctl request: name=%q args=%v", name, args) + } + return &unix.KinfoProc{Proc: unix.ExternProc{P_stat: darwinProcessStateZombie}}, nil + } + t.Cleanup(func() { readDarwinKinfoProc = old }) + + info := inspectWakeProcessPlatform(os.Getpid()) + if info.Running { + t.Fatalf("zombie process reported running: %#v", info) + } +} + +func stubDarwinBootIdentityReaders( + t *testing.T, + session func() (string, error), + bootTime func() (*unix.Timeval, error), +) { + t.Helper() + oldSession := readDarwinBootSessionUUID + oldBootTime := readDarwinBootTime + readDarwinBootSessionUUID = session + readDarwinBootTime = bootTime + t.Cleanup(func() { + readDarwinBootSessionUUID = oldSession + readDarwinBootTime = oldBootTime + }) +} + +func TestDarwinBootIdentityPrefersBootSessionUUIDAndKeepsLegacyAlias(t *testing.T) { + bootTime := unix.NsecToTimeval(1783327533407566000) + stubDarwinBootIdentityReaders(t, + func() (string, error) { return " 9C0682F4-901B-4243-8B5C-287FAFB9AD0E\n", nil }, + func() (*unix.Timeval, error) { return &bootTime, nil }, + ) + + bootID, legacyBootID := darwinBootIdentity() + if bootID != "9C0682F4-901B-4243-8B5C-287FAFB9AD0E" { + t.Fatalf("boot id = %q", bootID) + } + if legacyBootID != "1783327533.407566000" { + t.Fatalf("legacy boot id = %q", legacyBootID) + } +} + +func TestDarwinBootIdentityFallsBackToBootTime(t *testing.T) { + bootTime := unix.NsecToTimeval(1783327533407566000) + stubDarwinBootIdentityReaders(t, + func() (string, error) { return "", errors.New("unsupported") }, + func() (*unix.Timeval, error) { return &bootTime, nil }, + ) + + bootID, legacyBootID := darwinBootIdentity() + if bootID != "1783327533.407566000" { + t.Fatalf("fallback boot id = %q", bootID) + } + if legacyBootID != "" { + t.Fatalf("fallback legacy boot id = %q, want empty", legacyBootID) + } +} + +func TestDarwinBootIdentityReturnsEmptyWhenBothSourcesUnavailable(t *testing.T) { + stubDarwinBootIdentityReaders(t, + func() (string, error) { return "\x00", nil }, + func() (*unix.Timeval, error) { return nil, errors.New("unsupported") }, + ) + + bootID, legacyBootID := darwinBootIdentity() + if bootID != "" || legacyBootID != "" { + t.Fatalf("boot ids = %q, %q; want empty", bootID, legacyBootID) + } +} diff --git a/internal/cli/wake_unix_test.go b/internal/cli/wake_unix_test.go index bf8f15e..fa23a90 100644 --- a/internal/cli/wake_unix_test.go +++ b/internal/cli/wake_unix_test.go @@ -1362,6 +1362,34 @@ func TestShouldReplaceOrphanedWakeLockKeepsLockWhenKillDoesNotTerminate(t *testi } } +func TestTerminateWakeProcessPreservesLiveWakeOnUnknownBootAfterSignal(t *testing.T) { + const wakePID = 4343 + root := secureTempDirForTest(t) + lockPath := writeWakeLockForTest(t, root, "codex", wakeLock{PID: wakePID, TTY: "tty", ProcessStart: "start-1", BootID: "boot-1", Executable: "/opt/homebrew/bin/amq"}) + calls := 0 + stubInspectWakeProcess(t, func(pid int) wakeProcessInfo { + calls++ + info := wakeProcessInfo{PID: pid, Running: true, StartToken: "start-1", Executable: "/opt/homebrew/bin/amq", Args: []string{"/opt/homebrew/bin/amq", "wake", "--root", root, "--me", "codex"}} + if calls >= 3 { // after SIGTERM: still live, but boot identity is unavailable + return info + } + info.BootID = "boot-1" + return info + }) + var signals []os.Signal + stubSignalWakeProcess(t, func(pid int, sig os.Signal) error { signals = append(signals, sig); return nil }) + inspection := inspectWakeLock(root, "codex") + if err := terminateWakeProcess(inspection); err == nil { + t.Fatal("terminateWakeProcess unexpectedly declared success for live wake with unknown boot") + } + if len(signals) != 1 || signals[0] != syscall.SIGTERM { + t.Fatalf("signals = %v, want only SIGTERM", signals) + } + if _, err := os.Stat(lockPath); err != nil { + t.Fatalf("lock was removed or became unreadable: %v", err) + } +} + func TestShouldReplaceOrphanedWakeLockRevalidatesBeforeSignal(t *testing.T) { const wakePID = 4242 root := secureTempDirForTest(t)