mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
fix(daemon): close login-session watch races
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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<LoginPreflightOutcome>((resolve) => {
|
||||
resolveStartup = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.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<LoginPreflightOutcome>((resolve) => {
|
||||
resolvePeriodic = resolve
|
||||
})
|
||||
const probe = vi
|
||||
.fn<MacosLoginSessionDeathWatchOptions['probeLoginSession']>()
|
||||
.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<LoginPreflightOutcome>(() => {})
|
||||
})
|
||||
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<SystemResolverHealth>((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()
|
||||
|
||||
|
||||
@@ -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<LoginPreflightOutcome | null>
|
||||
readResolverHealth: () => Promise<SystemResolverHealth>
|
||||
probeLoginSession: (signal?: AbortSignal) => Promise<LoginPreflightOutcome | null>
|
||||
readResolverHealth: (signal?: AbortSignal) => Promise<SystemResolverHealth>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const resolverHealth = await this.readResolverHealth()
|
||||
private async retireIfResolverDegraded(
|
||||
holdAtThreshold: () => void,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
const resolverHealth = await this.readResolverHealth(signal)
|
||||
if (this.stopped || this.retired) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export async function readCurrentProcessMacSystemResolverHealth(): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin') {
|
||||
export async function readCurrentProcessMacSystemResolverHealth(
|
||||
signal?: AbortSignal
|
||||
): Promise<SystemResolverHealth> {
|
||||
if (process.platform !== 'darwin' || signal?.aborted) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
@@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
const onStderrData = (chunk: string): void => {
|
||||
stderr += chunk
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
child.kill('SIGKILL')
|
||||
finish()
|
||||
}
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
@@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.off('data', onStderrData)
|
||||
child.off('error', finish)
|
||||
child.off('close', finish)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
resolve(classifyMacSystemResolverHealth(`${stdout}\n${stderr}`))
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
@@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise<Syste
|
||||
child.stderr.on('data', onStderrData)
|
||||
child.on('error', finish)
|
||||
child.on('close', finish)
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('runMacosLoginSessionPtyProbe', () => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -33,7 +33,8 @@ export function runMacosLoginSessionPtyProbe(
|
||||
username: string,
|
||||
accountHome: string,
|
||||
timeoutMs: number,
|
||||
maxOutputBytes: number
|
||||
maxOutputBytes: number,
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome> {
|
||||
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) => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,8 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL'
|
||||
let cachedLoginPreflightResult: boolean | null = null
|
||||
let loginPreflightInFlight: Promise<LoginPreflightOutcome> | 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<LoginPreflightOutcome> {
|
||||
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<LoginPreflightOutcome | null> {
|
||||
export async function probeMacosLoginSessionAlive(
|
||||
signal?: AbortSignal
|
||||
): Promise<LoginPreflightOutcome | null> {
|
||||
if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) {
|
||||
return null
|
||||
}
|
||||
@@ -212,14 +222,27 @@ export async function probeMacosLoginSessionAlive(): Promise<LoginPreflightOutco
|
||||
if (!username || !accountHome) {
|
||||
return null
|
||||
}
|
||||
let outcome = await runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS)
|
||||
if (!outcome.conclusive) {
|
||||
outcome = await runMacosLoginSessionPtyProbe(
|
||||
username,
|
||||
accountHome,
|
||||
LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS,
|
||||
LOGIN_PREFLIGHT_MAX_BUFFER_BYTES
|
||||
)
|
||||
// Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict.
|
||||
const existingPreflight = loginPreflightInFlight
|
||||
loginSessionProbeInFlight = true
|
||||
loginPreflightCacheEpoch++
|
||||
let outcome: LoginPreflightOutcome
|
||||
try {
|
||||
outcome = await (existingPreflight ??
|
||||
runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal))
|
||||
if (!outcome.conclusive && !signal?.aborted) {
|
||||
outcome = await runMacosLoginSessionPtyProbe(
|
||||
username,
|
||||
accountHome,
|
||||
LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS,
|
||||
LOGIN_PREFLIGHT_MAX_BUFFER_BYTES,
|
||||
signal
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict.
|
||||
loginPreflightCacheEpoch++
|
||||
loginSessionProbeInFlight = false
|
||||
}
|
||||
if (outcome.conclusive) {
|
||||
cachedLoginPreflightResult = outcome.ok
|
||||
|
||||
Reference in New Issue
Block a user