From ea7033ef6e101a76a91540cd84e0fe0f5067a73e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:05:07 -0700 Subject: [PATCH] fix(daemon): do not read a stranded login wrapper as a hosted terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raised by a colleague's handoff on the same-day reports. macOS wraps every terminal in /usr/bin/login for TCC attribution, and #13764 shows the wrapper can outlive the shell it wrapped — leaving a session leader that hosts nothing. One affected host had accumulated enough of them to reach swap pressure. That is exactly the evidence this change treats as proof of live work, so a daemon whose sessions had all ended would have been held indefinitely on the strength of the corpses, on precisely the hosts where the problem is worst. Same class as the daemon's own probe PTYs: a session leader is necessary evidence, not sufficient. A wrapper still doing its job has the shell it exec'd beneath it. Co-authored-by: Orca --- .../daemon/daemon-live-pty-evidence.test.ts | 29 +++++++++++++++++++ src/main/daemon/daemon-live-pty-evidence.ts | 18 +++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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) } }