From 657cdcfc1ec2bb4338eeddde8f8cfc7ee513338f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:02:50 -0700 Subject: [PATCH] fix(daemon): close login-session watch races --- src/main/daemon/daemon-init.test.ts | 6 +- .../macos-login-session-death-watch.test.ts | 89 +++++++++++++++-- .../daemon/macos-login-session-death-watch.ts | 97 ++++++++++++++++--- src/main/daemon/terminal-host.test.ts | 3 + .../macos-system-resolver-health.test.ts | 17 ++++ .../network/macos-system-resolver-health.ts | 12 ++- .../macos-login-session-pty-probe.test.ts | 11 ++- .../macos-login-session-pty-probe.ts | 4 +- .../providers/macos-tcc-login-shell.test.ts | 67 ++++++++++++- src/main/providers/macos-tcc-login-shell.ts | 47 ++++++--- 10 files changed, 309 insertions(+), 44 deletions(-) diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index d6b0da6327f..042ebecd8e5 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -1113,7 +1113,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() - await mod.initDaemonPtyProvider() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, @@ -1164,6 +1164,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/socket', '--token', '/fake/token', + '--login-session-watch', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), @@ -1540,6 +1541,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') + const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[] + expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch') + expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts index eebfe14a177..1531a33ab20 100644 --- a/src/main/daemon/macos-login-session-death-watch.test.ts +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -225,15 +225,72 @@ describe('MacosLoginSessionDeathWatch', () => { watch.start() await drainMicrotasks() const after = probe.mock.calls.length - watch.notifyClientActivity() // too soon after startup probe - await drainMicrotasks() + watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe + await clock.advance(29_999) expect(probe.mock.calls.length).toBe(after) - await clock.advance(30_000) - watch.notifyClientActivity() - await drainMicrotasks() + await clock.advance(1) expect(probe.mock.calls.length).toBe(after + 1) }) + it('defers a PTY-exit trigger that lands inside the global probe gap', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] }) + watch.start() + await drainMicrotasks() + + watch.notifyPtyExit() + await clock.advance(4_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains one follow-up when a logout signal arrives during a probe', async () => { + let resolveStartup!: (outcome: LoginPreflightOutcome) => void + const startup = new Promise((resolve) => { + resolveStartup = resolve + }) + const probe = vi + .fn() + .mockReturnValueOnce(startup) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + watch.notifyPtyExit() + await clock.advance(2_000) + expect(probe).toHaveBeenCalledOnce() + + resolveStartup(ACCEPTED) + await drainMicrotasks() + await clock.advance(2_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains client activity that arrives during an armed periodic probe', async () => { + let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void + const periodic = new Promise((resolve) => { + resolvePeriodic = resolve + }) + const probe = vi + .fn() + .mockResolvedValueOnce(ACCEPTED) + .mockReturnValueOnce(periodic) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + + watch.notifyClientActivity() + resolvePeriodic(ACCEPTED) + await drainMicrotasks() + await clock.advance(29_999) + expect(probe).toHaveBeenCalledTimes(2) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(3) + }) + it('disables itself when the wrapper machinery does not apply', async () => { const { watch, clock, probe } = createWatch({ outcomes: [null] }) watch.start() @@ -255,14 +312,33 @@ describe('MacosLoginSessionDeathWatch', () => { expect(clock.pendingCount()).toBe(0) }) + it('stop() aborts an in-flight subprocess probe', async () => { + let probeSignal: AbortSignal | undefined + const probe = vi.fn((signal?: AbortSignal) => { + probeSignal = signal + return new Promise(() => {}) + }) + const { watch } = createWatch({ probeLoginSession: probe }) + watch.start() + expect(probeSignal?.aborted).toBe(false) + + watch.stop() + + expect(probeSignal?.aborted).toBe(true) + }) + it('stop() prevents an in-flight resolver check from retiring the daemon', async () => { let resolveHealth!: (health: SystemResolverHealth) => void + let resolverSignal: AbortSignal | undefined const resolverHealth = new Promise((resolve) => { resolveHealth = resolve }) const { watch, clock, onRetire } = createWatch({ outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED], - readResolverHealth: () => resolverHealth + readResolverHealth: (signal) => { + resolverSignal = signal + return resolverHealth + } }) watch.start() await drainMicrotasks() @@ -271,6 +347,7 @@ describe('MacosLoginSessionDeathWatch', () => { await clock.advance(10_000) // retirement is now waiting on resolver health watch.stop() + expect(resolverSignal?.aborted).toBe(true) resolveHealth('unhealthy') await drainMicrotasks() diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts index f08cf2dc03b..d0cadccac18 100644 --- a/src/main/daemon/macos-login-session-death-watch.ts +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -22,8 +22,8 @@ type WatchClock = { export type MacosLoginSessionDeathWatchOptions = { /** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */ - probeLoginSession: () => Promise - readResolverHealth: () => Promise + probeLoginSession: (signal?: AbortSignal) => Promise + readResolverHealth: (signal?: AbortSignal) => Promise onRetire: (details: { cause: 'pam-rejections' rejections: number @@ -78,7 +78,10 @@ export class MacosLoginSessionDeathWatch { private stopped = false private retired = false private scheduledProbeTimer: unknown | null = null + private scheduledProbeAtMs: number | null = null private ptyExitDebounceTimer: unknown | null = null + private pendingProbeTrigger: string | null = null + private probeAbortController: AbortController | null = null constructor(opts: MacosLoginSessionDeathWatchOptions) { this.probeLoginSession = opts.probeLoginSession @@ -110,9 +113,12 @@ export class MacosLoginSessionDeathWatch { stop(): void { this.stopped = true + this.probeAbortController?.abort() + this.probeAbortController = null if (this.scheduledProbeTimer !== null) { this.clock.clearTimeout(this.scheduledProbeTimer) this.scheduledProbeTimer = null + this.scheduledProbeAtMs = null } if (this.ptyExitDebounceTimer !== null) { this.clock.clearTimeout(this.ptyExitDebounceTimer) @@ -138,43 +144,87 @@ export class MacosLoginSessionDeathWatch { if (this.stopped) { return } - if ( - this.lastProbeStartedAtMs !== null && - this.clock.now() - this.lastProbeStartedAtMs < this.clientActivityMinGapMs - ) { + if (this.probeInFlight) { + if (this.armed) { + this.retainPendingProbe('client-hello') + } + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs + if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) { + // Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop. + this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello') return } void this.runProbe('client-hello') } - private scheduleNextProbe(delayMs: number): void { + private scheduleProbe(delayMs: number, trigger: string): void { if (this.stopped) { return } if (this.scheduledProbeTimer !== null) { this.clock.clearTimeout(this.scheduledProbeTimer) } + this.scheduledProbeAtMs = this.clock.now() + delayMs this.scheduledProbeTimer = this.clock.setTimeout(() => { this.scheduledProbeTimer = null - void this.runProbe('periodic') + this.scheduledProbeAtMs = null + void this.runProbe(trigger) }, delayMs) } - private async runProbe(trigger: string): Promise { - if (this.stopped || this.retired || this.probeInFlight) { + private scheduleNextProbe(delayMs: number): void { + this.scheduleProbe(delayMs, 'periodic') + } + + private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void { + const requestedAtMs = this.clock.now() + delayMs + if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) { return } + this.scheduleProbe(delayMs, trigger) + } + + private probeGapMs(trigger: string): number { + return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs + } + + private retainPendingProbe(trigger: string): void { + if ( + this.pendingProbeTrigger === null || + this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger) + ) { + this.pendingProbeTrigger = trigger + } + } + + private async runProbe(trigger: string): Promise { + if (this.stopped || this.retired) { + return + } + if (this.probeInFlight) { + // Why: the current probe may describe the pre-logout state; retain one follow-up without polling. + this.retainPendingProbe(trigger) + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs if ( trigger !== 'startup' && - this.lastProbeStartedAtMs !== null && - this.clock.now() - this.lastProbeStartedAtMs < this.minProbeGapMs + elapsedSinceProbe !== null && + elapsedSinceProbe < this.minProbeGapMs ) { + this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger) return } this.probeInFlight = true this.lastProbeStartedAtMs = this.clock.now() + const abortController = new AbortController() + this.probeAbortController = abortController try { - const outcome = await this.probeLoginSession() + const outcome = await this.probeLoginSession(abortController.signal) if (this.stopped || this.retired) { return } @@ -215,7 +265,7 @@ export class MacosLoginSessionDeathWatch { } await this.retireIfResolverDegraded(() => { this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 - }) + }, abortController.signal) } catch (error) { // Why: a probe failure is diagnostic only; an escaped rejection would trip the // daemon's fatal unhandled-error path and kill live terminals. @@ -223,11 +273,26 @@ export class MacosLoginSessionDeathWatch { this.scheduleNextProbe(this.periodicProbeMs) } finally { this.probeInFlight = false + if (this.probeAbortController === abortController) { + this.probeAbortController = null + } + const pendingTrigger = this.pendingProbeTrigger + this.pendingProbeTrigger = null + if (!this.stopped && !this.retired && pendingTrigger !== null) { + const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now()) + this.scheduleProbeNoLaterThan( + Math.max(0, this.probeGapMs(pendingTrigger) - elapsed), + pendingTrigger + ) + } } } - private async retireIfResolverDegraded(holdAtThreshold: () => void): Promise { - const resolverHealth = await this.readResolverHealth() + private async retireIfResolverDegraded( + holdAtThreshold: () => void, + signal: AbortSignal + ): Promise { + const resolverHealth = await this.readResolverHealth(signal) if (this.stopped || this.retired) { return } diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index cee72eee1cf..e7e83e31a31 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -883,6 +883,8 @@ describe('TerminalHost', () => { }) it('does not list exited sessions', async () => { + const onSessionReaped = vi.fn() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped }) await host.createOrAttach({ sessionId: 'session-1', cols: 80, @@ -892,6 +894,7 @@ describe('TerminalHost', () => { lastSubprocess._onExitCb?.(0) expect(host.listSessions()).toEqual([]) + expect(onSessionReaped).toHaveBeenCalledWith('session-1') }) it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => { diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 7cd29d6e22b..804847e6bbf 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -132,6 +132,23 @@ resolver #1 expect(child.listenerCount('close')).toBe(0) }) + it('kills scutil and removes listeners when its owner stops', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + const abortController = new AbortController() + + const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal) + abortController.abort() + + await expect(healthPromise).resolves.toBe('unknown') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + it('removes scutil listeners when the child closes normally', async () => { mockPlatform('darwin') const child = createMockScutilProcess() diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 3545db1963c..9a3dd6f1eb6 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes return 'unknown' } -export async function readCurrentProcessMacSystemResolverHealth(): Promise { - if (process.platform !== 'darwin') { +export async function readCurrentProcessMacSystemResolverHealth( + signal?: AbortSignal +): Promise { + if (process.platform !== 'darwin' || signal?.aborted) { return 'unknown' } @@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { stderr += chunk } + const onAbort = (): void => { + child.kill('SIGKILL') + finish() + } const finish = (): void => { if (settled) { return @@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { @@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { }) it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => { + const abortController = new AbortController() execFileMock.mockImplementation( (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '') @@ -28,11 +29,9 @@ describe('runMacosLoginSessionPtyProbe', () => { } ) - await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ - ok: true, - conclusive: true, - reason: 'accepted' - }) + await expect( + runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal) + ).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' }) expect(execFileMock).toHaveBeenCalledWith( '/usr/bin/expect', [ @@ -46,11 +45,13 @@ describe('runMacosLoginSessionPtyProbe', () => { env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }), killSignal: 'SIGKILL', maxBuffer: 1_024, + signal: abortController.signal, timeout: 4_000 }), expect.any(Function) ) expect(stdinEndMock).toHaveBeenCalledOnce() + expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof') }) it('treats a natural exit without the marker as a conclusive rejection', async () => { diff --git a/src/main/providers/macos-login-session-pty-probe.ts b/src/main/providers/macos-login-session-pty-probe.ts index 2a514c1a1a6..ffcc2098482 100644 --- a/src/main/providers/macos-login-session-pty-probe.ts +++ b/src/main/providers/macos-login-session-pty-probe.ts @@ -33,7 +33,8 @@ export function runMacosLoginSessionPtyProbe( username: string, accountHome: string, timeoutMs: number, - maxOutputBytes: number + maxOutputBytes: number, + signal?: AbortSignal ): Promise { if (!existsSync(MACOS_EXPECT_PATH)) { return Promise.resolve({ ok: false, conclusive: false, reason: 'error' }) @@ -49,6 +50,7 @@ export function runMacosLoginSessionPtyProbe( env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username }, killSignal: 'SIGKILL', maxBuffer: maxOutputBytes, + signal, timeout: timeoutMs }, (error, stdout) => { diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index dd03a59cc86..6f1a66a90d3 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -27,6 +27,8 @@ import { } from './macos-tcc-login-shell' type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void +const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const +const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const describe('wrapShellSpawnForMacosTccAttribution', () => { let origPlatform: PropertyDescriptor | undefined @@ -397,6 +399,50 @@ describe('probeMacosLoginSessionAlive', () => { expect(execFileMock).toHaveBeenCalledTimes(2) }) + it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => { + setPlatform('darwin') + let finishPreflight!: ExecFileCallback + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + finishPreflight = callback + return { stdin: { end: stdinEndMock } } + } + ) + + const warmup = prepareMacosTccLoginShell() + const freshProbe = probeMacosLoginSessionAlive() + expect(execFileMock).toHaveBeenCalledOnce() + + finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([ + ACCEPTED_OUTCOME, + ACCEPTED_OUTCOME + ]) + expect(execFileMock).toHaveBeenCalledOnce() + }) + + it('does not let a spawn-path probe overwrite a newer death verdict', async () => { + setPlatform('darwin') + const callbacks: ExecFileCallback[] = [] + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callbacks.push(callback) + return { stdin: { end: stdinEndMock } } + } + ) + + const freshProbe = probeMacosLoginSessionAlive() + const spawnProbe = prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(2) + + callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME) + callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME) + + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => { setPlatform('darwin') await prepareMacosTccLoginShell() @@ -449,7 +495,7 @@ describe('probeMacosLoginSessionAlive', () => { const outcome = await probeMacosLoginSessionAlive() expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) expect(execFileMock).toHaveBeenCalledOnce() - expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024) + expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined) expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') }) @@ -483,4 +529,23 @@ describe('probeMacosLoginSessionAlive', () => { // Inconclusive must not disturb the cached acceptance. expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') }) + + it('does not start a PTY fallback after the watch cancels its pipe probe', async () => { + setPlatform('darwin') + const abortController = new AbortController() + abortController.abort() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + expect(ptyProbeMock).not.toHaveBeenCalled() + }) }) diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index 76ef1343825..9ee3a709314 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -36,6 +36,8 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL' let cachedLoginPreflightResult: boolean | null = null let loginPreflightInFlight: Promise | null = null let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null +let loginPreflightCacheEpoch = 0 +let loginSessionProbeInFlight = false function isDisabledByEnv(): boolean { const value = process.env[DISABLE_ENV_VAR] @@ -55,7 +57,8 @@ function loginPreflightRetryDelayMs(failureCount: number): number { function runLoginPreflight( username: string, accountHome: string, - timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS + timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS, + signal?: AbortSignal ): Promise { return new Promise((resolve) => { try { @@ -71,6 +74,7 @@ function runLoginPreflight( // captured diagnostics without blocking the PTY host's event loop. killSignal: 'SIGKILL', maxBuffer: LOGIN_PREFLIGHT_MAX_BUFFER_BYTES, + signal, timeout: timeoutMs }, (error, stdout) => { @@ -114,15 +118,17 @@ function loginPreflightSucceeds( return Promise.resolve(cached) } if (!loginPreflightInFlight) { + const cacheEpoch = loginPreflightCacheEpoch // Why: simultaneous pane restores share one PAM child instead of multiplying // subprocesses at exactly the point terminal startup is already busiest. loginPreflightInFlight = runLoginPreflight(username, accountHome).then((outcome) => { // Why: cache only a conclusive PAM verdict; a killed/timed-out probe is // environmental and must be retried next spawn, not stuck forever (F1). - if (outcome.conclusive) { + const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch + if (outcome.conclusive && mayUpdateCache) { cachedLoginPreflightResult = outcome.ok transientLoginPreflightFailure = null - } else { + } else if (!outcome.conclusive && mayUpdateCache) { const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1 transientLoginPreflightFailure = { failureCount, @@ -185,6 +191,8 @@ export function resetMacosLoginShellPreflightForTests(): void { cachedLoginPreflightResult = null loginPreflightInFlight = null transientLoginPreflightFailure = null + loginPreflightCacheEpoch = 0 + loginSessionProbeInFlight = false } /** @@ -196,7 +204,9 @@ export function resetMacosLoginShellPreflightForTests(): void { * probe is inconclusive, since a dead session's PAM stack may only misbehave * under a real tty. Returns null when the wrapper doesn't apply. */ -export async function probeMacosLoginSessionAlive(): Promise { +export async function probeMacosLoginSessionAlive( + signal?: AbortSignal +): Promise { if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) { return null } @@ -212,14 +222,27 @@ export async function probeMacosLoginSessionAlive(): Promise