fix(terminal): treat macOS '??' tty as no terminal in spawn-file and tmux readers (#23251)

The daemon's spawn-file foreground and child-process readers and the tmux
client attachment reader compared root.tty against Linux's '?' only, so a
macOS root reported as '??' (or '-' on some ps builds) was read as having a
controlling terminal. All three now use the shared hasControllingTty
predicate from src/shared/posix-controlling-terminal.ts.

Fixes #23245

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Aashish Mahato
2026-10-26 09:53:08 -07:00
committed by GitHub
co-authored by Neil
parent 6877b55c7d
commit fd26aef37c
4 changed files with 36 additions and 3 deletions
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProcessTableRow } from '../../shared/process-table-snapshot'
import type * as SnapshotReader from '../../shared/process-table-snapshot-reader'
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
import { inspectSpawnFileChildProcessesFromRows } from './pty-subprocess/spawn-file-child-processes'
import { resolveSpawnFileForegroundFromRows } from './pty-subprocess/spawn-file-foreground-process'
import { inspectTerminalHostProcess } from './terminal-host-process-inspection'
import { Session } from './session'
@@ -226,6 +227,35 @@ it('ignores stopped/background children and another terminal beneath the same ro
})
})
// ps spells "no controlling terminal" as `?` on Linux, `??` on macOS and `-` on some builds.
describe.each(['??', '?', '-'] as const)('root on no controlling terminal (tty=%s)', (tty) => {
const detachedRoot: ProcessTableRow = {
pid: 100,
ppid: 1,
pgid: 100,
tpgid: 100,
tty,
startTime: 'Sat Sep 26 13:44:29 2026',
stat: 'S',
command: '/bin/zsh'
}
const detachedTree: ProcessTableRow[] = [
detachedRoot,
{ ...detachedRoot, pid: 101, ppid: 100, pgid: 100, command: 'claude' }
]
it('declines to name the descendant that shares the group but holds no terminal', () => {
expect(resolveSpawnFileForegroundFromRows(detachedTree, 100)).toEqual({
available: false,
processName: null
})
})
it('reports child evidence as unverifiable rather than reading an unfenceable table', () => {
expect(inspectSpawnFileChildProcessesFromRows(detachedTree, 100, 'zsh')).toBe('unverifiable')
})
})
it('uses Windows job membership and the native process table for ordinary children', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
@@ -7,6 +7,7 @@ import {
collectDescendantsFromIndex,
getProcessTableIndex
} from '../../../shared/process-table-index'
import { hasControllingTty } from '../../../shared/posix-controlling-terminal'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import type { PtyChildProcessVerdict } from '../../../shared/terminal-process-inspection'
import { readWindowsPtyJobProcessIds } from '../../providers/windows-pty-job-membership'
@@ -29,7 +30,7 @@ export function inspectSpawnFileChildProcessesFromRows(
): PtyChildProcessVerdict {
const index = getProcessTableIndex(rows)
const root = index.byPid.get(rootPid)
if (!root || !shellName || !root.tty || root.tty === '?') {
if (!root || !shellName || !hasControllingTty(root.tty)) {
return 'unverifiable'
}
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)]
@@ -8,6 +8,7 @@ import {
collectDescendantsFromIndex,
getProcessTableIndex
} from '../../../shared/process-table-index'
import { hasControllingTty } from '../../../shared/posix-controlling-terminal'
import type { ProcessTableRow } from '../../../shared/process-table-snapshot'
import {
getFreshProcessTableSnapshot,
@@ -52,7 +53,7 @@ export function resolveSpawnFileForegroundFromRows(
): AgentForegroundProcessResolution {
const index = getProcessTableIndex(rows)
const root = index.byPid.get(rootPid)
if (!root || !root.tpgid || root.tpgid < 0 || !root.tty || root.tty === '?') {
if (!root || !root.tpgid || root.tpgid < 0 || !hasControllingTty(root.tty)) {
return { available: false, processName: null }
}
const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, rootPid)]
+2 -1
View File
@@ -1,3 +1,4 @@
import { hasControllingTty } from './posix-controlling-terminal'
import type { ProcessTableRow } from './process-table-snapshot'
export type TmuxHookPane = { socket: string; pane: string }
@@ -51,7 +52,7 @@ export function resolveTmuxClientAttachment(
): TmuxAttachedClient | null {
const byPid = new Map(rows.map((row) => [row.pid, row]))
const root = byPid.get(rootPid)
if (!root?.startTime || !root.tty || root.tty === '?' || !root.tpgid || root.tpgid <= 0) {
if (!root?.startTime || !hasControllingTty(root.tty) || !root.tpgid || root.tpgid <= 0) {
return null
}
const attached = clients.filter((client) => {