mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user