diff --git a/src/main/daemon/daemon-live-pty-evidence.test.ts b/src/main/daemon/daemon-live-pty-evidence.test.ts index 14b21095474..e453ff000f8 100644 --- a/src/main/daemon/daemon-live-pty-evidence.test.ts +++ b/src/main/daemon/daemon-live-pty-evidence.test.ts @@ -163,6 +163,35 @@ describe('inspectDaemonPtyOwnership on POSIX', () => { ).resolves.toBe('owns-live-ptys') }) + it('ignores a login wrapper stranded without its shell (#13764)', async () => { + // Why: the macOS TCC wrapper can outlive the shell it wrapped, leaving a session leader + // hosting nothing. On hosts where those accumulate, counting them would hold a daemon + // whose sessions have all ended — indefinitely, and for no live work. + await expect( + inspectDaemonPtyOwnership(DAEMON_PID, { + platform: 'darwin', + readPosixProcessTable: posixTable([ + daemonRow, + row(101, DAEMON_PID, { command: '/usr/bin/login -flpq nwparker /bin/bash …' }), + row(102, DAEMON_PID, { command: '/usr/bin/login -flpq nwparker /bin/bash …' }) + ]) + }) + ).resolves.toBe('no-live-ptys') + }) + + it('still counts a login wrapper that has its shell', async () => { + await expect( + inspectDaemonPtyOwnership(DAEMON_PID, { + platform: 'darwin', + readPosixProcessTable: posixTable([ + daemonRow, + row(101, DAEMON_PID, { command: '/usr/bin/login -flpq nwparker /bin/bash …' }), + row(202, 101, { command: '/opt/homebrew/bin/bash --rcfile …' }) + ]) + }) + ).resolves.toBe('owns-live-ptys') + }) + it('reports unknown when the table never contained the daemon', async () => { // Why: an unobserved root yields the same empty result as a childless one — // reading that as "empty" authorizes killing a daemon full of live agents. diff --git a/src/main/daemon/daemon-live-pty-evidence.ts b/src/main/daemon/daemon-live-pty-evidence.ts index c5b398f02bd..b1342f27125 100644 --- a/src/main/daemon/daemon-live-pty-evidence.ts +++ b/src/main/daemon/daemon-live-pty-evidence.ts @@ -49,6 +49,9 @@ export const WINDOWS_OWNERSHIP_PROBE_DEADLINE_MS = 6_000 */ export const POSIX_OWNERSHIP_PROBE_DEADLINE_MS = 4_000 +/** macos-tcc-login-shell.ts wraps every darwin terminal in this. */ +const MACOS_LOGIN_WRAPPER_PREFIX = '/usr/bin/login ' + function withDeadline(work: Promise, deadlineMs: number, onDeadline: T): Promise { return new Promise((resolve) => { const timer = setTimeout(() => resolve(onDeadline), deadlineMs) @@ -111,6 +114,16 @@ function isLivePtySessionLeader(row: ProcessTableRow): boolean { return !row.stat.startsWith('Z') && row.stat.includes('s') && !isDaemonSelfSpawnedPty(row) } +/** + * macOS wraps every terminal in `/usr/bin/login` for TCC attribution, and the wrapper can + * outlive the shell it wrapped (#13764) — a session leader hosting nothing. Counting those + * would hold a daemon whose sessions have all ended, on hosts where they accumulate by the + * hundred. A wrapper still doing its job always has the shell it exec'd beneath it. + */ +function isStrandedLoginWrapper(row: ProcessTableRow, hasChildren: boolean): boolean { + return !hasChildren && row.command.trim().startsWith(MACOS_LOGIN_WRAPPER_PREFIX) +} + function collectLivePtyDescendants(rows: ProcessTableRow[], rootPid: number): ProcessTableRow[] { const childrenByPpid = new Map() for (const row of rows) { @@ -135,7 +148,10 @@ function collectLivePtyDescendants(rows: ProcessTableRow[], rootPid: number): Pr } visited.add(child.pid) queue.push(child.pid) - if (isLivePtySessionLeader(child)) { + if ( + isLivePtySessionLeader(child) && + !isStrandedLoginWrapper(child, childrenByPpid.has(child.pid)) + ) { live.push(child) } }