From fd26aef37c5f512eb75cd36828ae49cb1b7e0bc6 Mon Sep 17 00:00:00 2001 From: Aashish Mahato <145881415+aashish254@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:38:08 +0545 Subject: [PATCH] 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> --- ...y-subprocess-spawn-file-foreground.test.ts | 30 +++++++++++++++++++ .../spawn-file-child-processes.ts | 3 +- .../spawn-file-foreground-process.ts | 3 +- src/shared/tmux-client-attachment.ts | 3 +- 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts index 8a373616d81..35f330078cc 100644 --- a/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts +++ b/src/main/daemon/pty-subprocess-spawn-file-foreground.test.ts @@ -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 }) diff --git a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts index c07e3f77e7a..76d7767b56b 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-child-processes.ts @@ -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)] diff --git a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts index b5763f238b1..46c2d5f4696 100644 --- a/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts +++ b/src/main/daemon/pty-subprocess/spawn-file-foreground-process.ts @@ -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)] diff --git a/src/shared/tmux-client-attachment.ts b/src/shared/tmux-client-attachment.ts index 6184ebfc247..653994f618b 100644 --- a/src/shared/tmux-client-attachment.ts +++ b/src/shared/tmux-client-attachment.ts @@ -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) => {