fix(ssh): close the gaps in the macOS launch-session warning (#29487)

* fix(ssh): finish the macOS launch-session warning's gaps (#29382)

Check it on automatic restores, once per SSH connection; create the data root
before recording; inherit a session only from this root's verified daemon; and
let only a verified restart clear the warning.

* fix(ssh): recheck the launch session per reconnect generation, after the restore update

* fix(ssh): reapply a known ended session to a replaced status; leave an unreadable daemon's attribution unknown

* fix(ssh): expire live launch-session verdicts, and drop an answer outlived by its connection or server
This commit is contained in:
Neil
2026-10-15 23:40:23 -07:00
committed by GitHub
parent 6b5cd3246a
commit 971e4f26cc
7 changed files with 341 additions and 33 deletions
@@ -14,7 +14,7 @@ vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
getSshTargetRegistryStore: () => null
}))
const { clearManagedServerNotes, publishHostServerStatus } =
const { clearLaunchSessionNote, clearManagedServerNotes, publishHostServerStatus } =
await import('./runtime-environment-managed-tunnel')
const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } =
await import('../ssh/ssh-host-server-status')
@@ -108,4 +108,13 @@ describe('publishing a verified managed host status', () => {
expect(getSshHostServerStatus('ssh-2')).toBeUndefined()
expect(mocks.notify).not.toHaveBeenCalled()
})
it('keeps the launch-session warning through an update; only a verified restart clears it', () => {
mocks.getState.mockReturnValue(connection('connected'))
setSshHostServerStatus('ssh-1', { ...deferred, launchSessionEnded: true })
clearManagedServerNotes('ssh-1', 'env-1')
expect(getSshHostServerStatus('ssh-1')).toEqual({ ...ready, launchSessionEnded: true })
clearLaunchSessionNote('ssh-1', 'env-1')
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
})
})
+4
View File
@@ -12,6 +12,8 @@ import { getOrcadSshDeployment } from '../../shared/runtime-environments'
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import type { OrcadManagedRestartResult } from '../../shared/orcad-managed-runtime'
import { restartManagedOrcadEnvironment } from '../ssh/orcad-managed-launch-session'
import { clearLaunchSessionNote } from './runtime-environment-managed-tunnel'
import { forgetLaunchSessionVerdict } from './ssh-host-launch-session-note'
import type { ManagedServerActions } from '../runtime/managed-server-actions-registry'
import {
retireRemovedRuntimeEnvironment,
@@ -104,6 +106,8 @@ export async function restartManagedOrcad(
const targetId = getOrcadSshDeployment(environment)?.sshTargetId
if (targetId) {
options.clearHostServerNotes(targetId, result.environmentId)
forgetLaunchSessionVerdict(targetId)
clearLaunchSessionNote(targetId, result.environmentId)
}
}
return result
@@ -58,7 +58,18 @@ export async function resolveManagedRuntimeEnvironment(
}
} else {
// Why here: an auto-restored host may never see an SSH connect, so it would never update.
void updateManagedOrcadOnRestore(environment.id, createDeps)
const update = updateManagedOrcadOnRestore(environment.id, createDeps)
const targetId = getOrcadSshDeployment(environment)?.sshTargetId
if (targetId) {
// Why after the update: its settled status would otherwise replace the warning.
// Why lazy: the note publishes through this module.
void Promise.resolve(update)
.then(() => import('./ssh-host-launch-session-note'))
.then(({ noteEndedLaunchSshSession }) =>
noteEndedLaunchSshSession(targetId, environment.id)
)
.catch(() => {})
}
}
return resolveEnvironment(userDataPath, environment.id)
}
@@ -126,7 +137,21 @@ export function clearManagedServerNotes(targetId: string, environmentId: string)
if (current?.kind !== 'managed' || current.environmentId !== environmentId) {
return
}
if (current.update || current.serving || current.launchSessionEnded) {
publishHostServerStatus(targetId, { kind: 'managed', environmentId })
if (current.update || current.serving) {
// An update can keep the terminal daemon, so only a verified restart clears that warning.
publishHostServerStatus(targetId, {
kind: 'managed',
environmentId,
...(current.launchSessionEnded ? { launchSessionEnded: true as const } : {})
})
}
}
/** A restart that verified a fresh daemon and a live launching session clears the warning. */
export function clearLaunchSessionNote(targetId: string, environmentId: string): void {
const current = getSshHostServerStatus(targetId)
if (current?.kind === 'managed' && current.environmentId === environmentId) {
const { launchSessionEnded: _cleared, ...rest } = current
publishHostServerStatus(targetId, rest)
}
}
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshManagedServerStatus } from '../../shared/ssh-types'
const mocks = vi.hoisted(() => {
const status: { current: SshManagedServerStatus | undefined } = { current: undefined }
const os: { current: string } = { current: 'darwin' }
const connection: { current: { getConnectGeneration: () => number } } = {
current: { getConnectGeneration: () => 1 }
}
const environments: { current: string[] } = { current: ['env-1'] }
return { read: vi.fn(), publish: vi.fn(), os, connection, status, environments }
})
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getPath: () => '/u' })
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: () => mocks.environments.current.map((id) => ({ id }))
}))
vi.mock('../ssh/orcad-managed-launch-session', () => ({
readManagedOrcadLaunchSshSession: mocks.read
}))
vi.mock('../ssh/ssh-host-platform-memo', () => ({
knownSshHostPlatform: () => ({ os: mocks.os.current })
}))
vi.mock('../ssh/ssh-host-server-status', () => ({
getSshHostServerStatus: () => mocks.status.current
}))
vi.mock('./ssh-ipc-context', () => ({
connectionManager: { getConnection: () => mocks.connection.current }
}))
vi.mock('./runtime-environment-managed-tunnel', () => ({ publishHostServerStatus: mocks.publish }))
const { forgetLaunchSessionVerdict, noteEndedLaunchSshSession } =
await import('./ssh-host-launch-session-note')
beforeEach(() => {
vi.clearAllMocks()
mocks.os.current = 'darwin'
mocks.connection.current = { getConnectGeneration: () => 1 }
mocks.status.current = undefined
mocks.environments.current = ['env-1']
mocks.read.mockResolvedValue('exited')
})
describe('noteEndedLaunchSshSession', () => {
it('flags a restored host that has no status yet', async () => {
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).toHaveBeenCalledWith('ssh-1', {
kind: 'managed',
environmentId: 'env-1',
launchSessionEnded: true
})
})
it('checks once per SSH connection, and again on a new one', async () => {
await noteEndedLaunchSshSession('ssh-1', 'env-1')
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.read).toHaveBeenCalledOnce()
mocks.connection.current = { getConnectGeneration: () => 1 }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.read).toHaveBeenCalledTimes(2)
})
it('checks again after the same connection reconnects', async () => {
let generation = 1
mocks.connection.current = { getConnectGeneration: () => generation }
mocks.read.mockResolvedValue('live')
await noteEndedLaunchSshSession('ssh-1', 'env-1')
generation = 2
mocks.read.mockResolvedValue('exited')
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).toHaveBeenCalledOnce()
})
it('retries when a start or update was still deciding', async () => {
mocks.status.current = { kind: 'setting-up', phase: 'updating' }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).not.toHaveBeenCalled()
mocks.status.current = { kind: 'managed', environmentId: 'env-1' }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).toHaveBeenCalledOnce()
})
it('reapplies a known ended session to a status a later connect replaced', async () => {
await noteEndedLaunchSshSession('ssh-1', 'env-1')
mocks.status.current = { kind: 'managed', environmentId: 'env-1' }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.read).toHaveBeenCalledOnce()
expect(mocks.publish).toHaveBeenLastCalledWith('ssh-1', {
kind: 'managed',
environmentId: 'env-1',
launchSessionEnded: true
})
})
it('probes again after a verified restart forgets the verdict', async () => {
await noteEndedLaunchSshSession('ssh-1', 'env-1')
forgetLaunchSessionVerdict('ssh-1')
mocks.read.mockResolvedValue('live')
mocks.status.current = { kind: 'managed', environmentId: 'env-1' }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.read).toHaveBeenCalledTimes(2)
expect(mocks.publish).toHaveBeenCalledOnce()
})
it('probes again once a live verdict is old', async () => {
let clock = 0
mocks.read.mockResolvedValue('live')
await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock)
clock = 60_000
await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock)
expect(mocks.read).toHaveBeenCalledOnce()
clock = 11 * 60_000
await noteEndedLaunchSshSession('ssh-1', 'env-1', () => clock)
expect(mocks.read).toHaveBeenCalledTimes(2)
})
it('drops an answer whose server was unlinked or whose connection changed meanwhile', async () => {
mocks.read.mockImplementationOnce(async () => {
mocks.environments.current = []
return 'exited'
})
await noteEndedLaunchSshSession('ssh-1', 'env-1')
mocks.read.mockImplementationOnce(async () => {
mocks.connection.current = { getConnectGeneration: () => 1 }
return 'exited'
})
mocks.environments.current = ['env-1']
mocks.connection.current = { getConnectGeneration: () => 1 }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).not.toHaveBeenCalled()
})
it('never probes a Linux or Windows host', async () => {
mocks.os.current = 'linux'
await noteEndedLaunchSshSession('ssh-1', 'env-1')
mocks.os.current = 'win32'
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.read).not.toHaveBeenCalled()
})
it.each(['live', 'unverifiable', 'not-recorded'])('shows nothing for %s', async (verdict) => {
mocks.read.mockResolvedValue(verdict)
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).not.toHaveBeenCalled()
})
it('leaves another server decision alone', async () => {
mocks.status.current = { kind: 'relay', reason: 'source_changed' }
await noteEndedLaunchSshSession('ssh-1', 'env-1')
expect(mocks.publish).not.toHaveBeenCalled()
})
})
+69 -9
View File
@@ -1,34 +1,94 @@
/** After a managed connect: flags a macOS host whose server outlived its launching SSH session. */
/** After a managed connect or restore: flags a macOS host whose server outlived its launching SSH session. */
import { getAppEnvironment } from '../../shared/app-environment'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { readManagedOrcadLaunchSshSession } from '../ssh/orcad-managed-launch-session'
import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo'
import { getSshHostServerStatus } from '../ssh/ssh-host-server-status'
import { connectionManager } from './ssh-ipc-context'
import { publishHostServerStatus } from './runtime-environment-managed-tunnel'
/** Never blocks or fails the connect; anything but a proven exit shows nothing. */
// Once per SSH session: a restore runs on every runtime call, and the answer only changes when
// a session ends, which the next session (a new connection or reconnect generation) checks.
// The ended verdict is kept so a status a later connect replaces gets the warning back. Any
// other verdict expires: another desktop's launching session can end while this one stays.
const checked = new WeakMap<object, { generation: number; ended: boolean; at: number }>()
const RECHECK_MS = 10 * 60_000
/** Never blocks or fails the caller; anything but a proven exit shows nothing. */
export async function noteEndedLaunchSshSession(
targetId: string,
environmentId: string
environmentId: string,
now: () => number = Date.now
): Promise<void> {
// Why the memo: Linux and Windows hosts cost no extra round trip; the tunnel build fills it.
if (knownSshHostPlatform(targetId)?.os !== 'darwin') {
return
}
const connection = connectionManager?.getConnection(targetId)
if (!connection) {
return
}
const generation = connection.getConnectGeneration()
const prior = checked.get(connection)
if (prior?.generation === generation && (prior.ended || now() - prior.at < RECHECK_MS)) {
if (prior.ended) {
flag(targetId, environmentId)
}
return
}
checked.set(connection, { generation, ended: false, at: now() })
try {
const session = await readManagedOrcadLaunchSshSession(
getAppEnvironment().getPath('userData'),
environmentId
)
const status = getSshHostServerStatus(targetId)
// Why: a disconnect, reconnect or unlink during the probe makes its answer someone else's.
if (
session === 'exited' &&
status?.kind === 'managed' &&
status.environmentId === environmentId &&
!status.serving
connectionManager?.getConnection(targetId) !== connection ||
connection.getConnectGeneration() !== generation
) {
publishHostServerStatus(targetId, { ...status, launchSessionEnded: true })
return
}
const status = getSshHostServerStatus(targetId)
if (status?.kind === 'setting-up') {
// A start or update is still deciding; a later call checks again.
checked.delete(connection)
return
}
if (session === 'exited') {
checked.set(connection, { generation, ended: true, at: now() })
flag(targetId, environmentId)
}
} catch (error) {
console.warn('[ssh] Could not check which SSH session started the managed server:', error)
}
}
/** A restored host may have no status yet; any other server decision is left alone. */
function flag(targetId: string, environmentId: string): void {
const userDataPath = getAppEnvironment().getPath('userData')
if (!listEnvironments(userDataPath).some((entry) => entry.id === environmentId)) {
return
}
const status = getSshHostServerStatus(targetId)
if (
!status ||
(status.kind === 'managed' &&
status.environmentId === environmentId &&
!status.serving &&
!status.launchSessionEnded)
) {
publishHostServerStatus(targetId, {
...(status ?? { kind: 'managed', environmentId }),
launchSessionEnded: true
})
}
}
/** After a verified restart: the cached ended verdict no longer describes the host. */
export function forgetLaunchSessionVerdict(targetId: string): void {
const connection = connectionManager?.getConnection(targetId)
if (connection) {
checked.delete(connection)
}
}
+57 -15
View File
@@ -19,7 +19,7 @@ import { getRemoteHostPlatform } from './ssh-remote-platform'
const mac = getRemoteHostPlatform('darwin-arm64')
const linux = getRemoteHostPlatform('linux-x64')
// Table rows are `pid|ppid|comm|lstart`; a PID in FAKE_PS_GONE has no process, and any other
// Table rows are `pid|ppid|comm|lstart|args`; a PID in FAKE_PS_GONE has no process, and any other
// unlisted PID is the calling shell, whose parent is FAKE_PS_SHELL_PARENT.
const FAKE_PS = `#!/bin/sh
[ -n "$FAKE_PS_BROKEN" ] && exit 1
@@ -33,6 +33,7 @@ case "$field" in
ppid) echo " $(echo "$row" | cut -d'|' -f2)";;
comm) echo "$row" | cut -d'|' -f3;;
lstart) echo "$row" | cut -d'|' -f4;;
args) echo "$row" | cut -d'|' -f5;;
esac
`
@@ -65,6 +66,24 @@ function writeRecord(pid: string, record: string | null): void {
}
}
function publishDaemon(pid: number, args: string): void {
spawnSync('mkdir', [join(dir, 'daemon')])
writeFileSync(join(dir, 'daemon', 'daemon-v1.pid'), JSON.stringify({ pid, startedAtMs: 1 }))
writeFileSync(
join(dir, 'ps-table'),
`${TABLE}\n${pid}|1|/opt/homebrew/bin/node|Thu Oct 9 07:59:59 2026|${args}`
)
}
function launchRecord(env: Record<string, string> = {}): string {
const pidFile = join(dir, ORCAD_PID_FILENAME)
return sh(
`${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` +
`${writeOrcadLaunchSshSessionCommand(mac, dir)} && cat '${pidFile}'`,
env
).trim()
}
function probe(env: Record<string, string> = {}): string {
return parseOrcadLaunchSshSession(sh(orcadLaunchSshSessionProbeCommand(mac, dir, dir), env))
}
@@ -132,17 +151,11 @@ describe.skipIf(process.platform === 'win32')('orcad launch SSH session', () =>
it('carries the earlier session forward when the terminal daemon outlived orcad', () => {
writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026')
spawnSync('mkdir', [join(dir, 'daemon')])
// This test process stands in for the surviving daemon.
writeFileSync(
join(dir, 'daemon', 'daemon-v1.pid'),
JSON.stringify({ pid: process.pid, startedAtMs: 1 })
publishDaemon(
process.pid,
`node /x/daemon-entry.js --socket ${join(dir, 'daemon')}/daemon.sock`
)
const pidFile = join(dir, ORCAD_PID_FILENAME)
const pid = sh(
`${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` +
`${writeOrcadLaunchSshSessionCommand(mac, dir)} && cat '${pidFile}'`
).trim()
const pid = launchRecord()
expect(readFileSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME), 'utf8').trim()).toBe(
`${pid} 400 Thu Oct 9 07:59:59 2026`
)
@@ -150,14 +163,43 @@ describe.skipIf(process.platform === 'win32')('orcad launch SSH session', () =>
})
it('records nothing when a surviving daemon’s session was never recorded', () => {
spawnSync('mkdir', [join(dir, 'daemon')])
writeFileSync(join(dir, 'daemon', 'daemon-v1.pid'), JSON.stringify({ pid: process.pid }))
sh(
`${captureOrcadLaunchSshSessionCommand()} sleep 0 & ${writeOrcadLaunchSshSessionCommand(mac, dir)}`
publishDaemon(
process.pid,
`node /x/daemon-entry.js --socket ${join(dir, 'daemon')}/daemon.sock`
)
launchRecord()
expect(existsSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(false)
})
it.each([
['a stale PID file naming another process', '/usr/sbin/cron'],
['another data root’s daemon', 'node /x/daemon-entry.js --socket /other/daemon/daemon.sock']
])('records this launch’s own session over %s', (_case, args) => {
writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026')
publishDaemon(process.pid, args)
const pid = launchRecord()
expect(readFileSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME), 'utf8').trim()).toBe(
`${pid} 500 Thu Oct 9 08:00:00 2026`
)
})
it('leaves the attribution unknown when a live daemon’s command line can’t be read', () => {
writeRecord('1', '1 400 Thu Oct 9 07:59:59 2026')
publishDaemon(process.pid, '')
launchRecord()
expect(existsSync(join(dir, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(false)
})
it('creates the data root on a first deploy before recording', () => {
const data = join(dir, 'fresh', 'data')
const pidFile = join(dir, ORCAD_PID_FILENAME)
sh(
`${captureOrcadLaunchSshSessionCommand()} sleep 0 & echo $! > '${pidFile}' && ` +
`${writeOrcadLaunchSshSessionCommand(mac, data)}`
)
expect(existsSync(join(data, ORCAD_LAUNCH_SSH_SESSION_FILENAME))).toBe(true)
})
it('removes an earlier record when this launch finds no session', () => {
writeRecord('1', '1 500 Thu Oct 9 08:00:00 2026')
sh(
+19 -5
View File
@@ -57,14 +57,23 @@ function recordPath(host: RemoteHostPlatform, userDataDir: string): string {
return shellEscape(joinRemotePath(host, userDataDir, ORCAD_LAUNCH_SSH_SESSION_FILENAME))
}
/** True when a terminal daemon this data root published is still running. */
/**
* Returns 0 when a terminal daemon this data root published is still running, 1 when none is,
* and 2 when a live PID's command line can't be read. Like `commandLineMatchesDaemon`, a PID
* counts only when its command line is this root's daemon: a stale PID file after a reboot can
* name an unrelated process.
*/
function liveDaemonShellFunction(host: RemoteHostPlatform, userDataDir: string): string {
const daemonDir = shellEscape(joinRemotePath(host, userDataDir, 'daemon'))
return (
`orcad_daemon_live() { for orcad_df in ${daemonDir}/daemon-v*.pid; do ` +
`orcad_daemon_live() { orcad_ddir=${daemonDir}; for orcad_df in "$orcad_ddir"/daemon-v*.pid; do ` +
'[ -f "$orcad_df" ] || continue; ' +
'orcad_dp=$(sed -n \'s/.*"pid":\\([0-9][0-9]*\\).*/\\1/p\' "$orcad_df" | head -n 1); ' +
'[ -n "$orcad_dp" ] && kill -0 "$orcad_dp" 2>/dev/null && return 0; done; return 1; };'
'[ -n "$orcad_dp" ] || continue; ' +
'kill -0 "$orcad_dp" 2>/dev/null || continue; ' +
'orcad_da=$(ps -o args= -p "$orcad_dp" 2>/dev/null); [ -n "$orcad_da" ] || return 2; ' +
'case "$orcad_da" in *daemon-entry*"$orcad_ddir"* | *"$orcad_ddir"*daemon-entry*) return 0;; esac; ' +
'done; return 1; };'
)
}
@@ -78,10 +87,15 @@ export function writeOrcadLaunchSshSessionCommand(
): string {
const file = recordPath(host, userDataDir)
return (
`{ orcad_bg=$!; ${liveDaemonShellFunction(host, userDataDir)} ` +
'if orcad_daemon_live; then ' +
// Why mkdir: on a first deploy the detached server may not have created its data root yet.
`{ orcad_bg=$!; ( umask 077; mkdir -p ${shellEscape(userDataDir)} ); ` +
`${liveDaemonShellFunction(host, userDataDir)} ` +
'orcad_daemon_live; orcad_dl=$?; ' +
'if [ "$orcad_dl" = 0 ]; then ' +
`orcad_prev=$(cat ${file}); rm -f ${file}; set -- $orcad_prev; ` +
`[ "$#" -lt 3 ] || { shift; ( umask 077; echo "$orcad_bg" "$@" > ${file} ); }; ` +
// An unreadable daemon leaves the attribution unknown, never this launch's.
`elif [ "$orcad_dl" = 2 ]; then rm -f ${file}; ` +
`else rm -f ${file}; [ -z "$orcad_session" ] || ` +
`( umask 077; echo "$orcad_bg" $orcad_session > ${file} ); fi; :; } 2>/dev/null`
)