diff --git a/src/main/managed-server/install/orcad-remote-host-support.ts b/src/main/managed-server/install/orcad-remote-host-support.ts index db44dd5ff8b..5a1549ebc85 100644 --- a/src/main/managed-server/install/orcad-remote-host-support.ts +++ b/src/main/managed-server/install/orcad-remote-host-support.ts @@ -45,6 +45,20 @@ export function posixProcessAliveShellFunction( : 'kill -0 "$1" 2>/dev/null || return 1; ' return ( `orcad_alive() { ${probe}` + - 'case "$(ps -o stat= -p "$1" 2>/dev/null)" in Z*) return 1;; esac; return 0; };' + 'orcad_stat=$(ps -o stat= -p "$1" 2>/dev/null) && [ -n "$orcad_stat" ] || ' + + // Field 3 of /proc//stat follows the last ")": comm itself may hold spaces or parens. + 'orcad_stat=$(cat "/proc/$1/stat" 2>/dev/null) && orcad_stat=${orcad_stat##*") "}; ' + + 'case "$orcad_stat" in Z*) return 1;; esac; return 0; };' + ) +} + +/** Prints a PID's argv; slim Linux images ship no ps, so fall back to /proc (#34149). */ +export function posixProcessArgsShellFunction(): string { + return ( + 'orcad_args() { orcad_argv=$(ps -o args= -p "$1" 2>/dev/null) && [ -n "$orcad_argv" ] || ' + + '{ orcad_argv=$(tr "\\000" " " 2>/dev/null < "/proc/$1/cmdline") || return 1; ' + + // Kernel threads and zombies have no argv; their stat line still names them. + '[ -n "$orcad_argv" ] || orcad_argv=$(cat "/proc/$1/stat" 2>/dev/null) || return 1; }; ' + + 'printf "%s\\n" "$orcad_argv"; };' ) } diff --git a/src/main/managed-server/install/orcad-remote-launch.ts b/src/main/managed-server/install/orcad-remote-launch.ts index 52da6decb44..6fa6c37eaff 100644 --- a/src/main/managed-server/install/orcad-remote-launch.ts +++ b/src/main/managed-server/install/orcad-remote-launch.ts @@ -22,7 +22,8 @@ import { ORCAD_PID_MACHINE_FILENAME, ORCAD_READINESS_FILENAME, ORCAD_READINESS_MAX_BYTES, - posixProcessAliveShellFunction + posixProcessAliveShellFunction, + posixProcessArgsShellFunction } from './orcad-remote-host-support' import type { ServeReadiness } from '../../server/serve-readiness' import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime' @@ -219,12 +220,13 @@ export function orcadLivenessProbeCommand( ) return [ posixProcessAliveShellFunction({ refuseUnverifiable: true }), + posixProcessArgsShellFunction(), // PID reuse needs argv identity; absent ps leaves conservative liveness checks to decide. `orcad_launcher=${launcher}; orcad_server=${server};`, // The probe's own shell, or the shell wrapping it, carries the slot paths in its argv; after a // restart either can hold the old PID. A shell elsewhere may be an older launcher's live parent. 'orcad_reused() { { [ "$1" = "$$" ] || [ "$1" = "$PPID" ]; } && return 0; ' + - 'args=$(ps -o args= -p "$1" 2>/dev/null) && [ -n "$args" ] && ' + + 'args=$(orcad_args "$1") && [ -n "$args" ] && ' + 'case "$args" in *"$orcad_launcher"* | *"$orcad_server"*) return 1;; *) return 0;; esac; };', `pid=$(cat ${pidFile} 2>/dev/null);`, 'case "$pid" in', diff --git a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts index 1388e5ce2dc..b3553216f82 100644 --- a/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts +++ b/src/main/managed-server/install/orcad-remote-shell-commands.integration.test.ts @@ -532,6 +532,33 @@ describe('liveness and stop commands, run for real', () => { } }) + // Slim Linux images ship no ps; after a reboot the recorded PID often names a new process (#34149). + it.runIf(process.platform === 'linux')( + 'reads argv from /proc on a host without ps, so a reused PID is DEAD and the slot is LIVE', + () => { + const noPs = 'ps() { return 127; };' + const stranger = spawn('/bin/sh', ['-c', 'sleep 30'], { stdio: 'ignore' }) + const slot = spawn( + process.execPath, + ['-e', 'setTimeout(() => {}, 30000)', join(versionDir, 'orcad.js')], + { stdio: 'ignore' } + ) + try { + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(stranger.pid)) + expect( + parseOrcadLiveness(sh(`${noPs} ${orcadLivenessProbeCommand(host, versionDir)}`)) + ).toBe('DEAD') + writeFileSync(join(versionDir, ORCAD_PID_FILENAME), String(slot.pid)) + expect( + parseOrcadLiveness(sh(`${noPs} ${orcadLivenessProbeCommand(host, versionDir)}`)) + ).toBe('LIVE') + } finally { + stranger.kill('SIGKILL') + slot.kill('SIGKILL') + } + } + ) + it.each(['orcad.js', 'orcad-server.js'])( 'reports LIVE and stops a running %s even without entry files', async (entry) => { @@ -574,6 +601,13 @@ describe('liveness and stop commands, run for real', () => { expect(sh(`ps -o stat= -p ${child.pid} || echo GONE`).trim()).toMatch(/^Z/) expect(sh(`kill -0 ${child.pid} 2>/dev/null && echo LIVE || echo DEAD`).trim()).toBe('LIVE') expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD') + if (process.platform === 'linux') { + expect( + parseOrcadLiveness( + sh(`ps() { return 127; }; ${orcadLivenessProbeCommand(host, versionDir)}`) + ) + ).toBe('DEAD') + } expect( parseOrcadStopOutcome( sh(stopOrcadCommand(host, versionDir, { waitSeconds: 1, justLaunched: true })) diff --git a/tests/e2e/ssh-orcad-idle-exit.spec.ts b/tests/e2e/ssh-orcad-idle-exit.spec.ts index f665c3661a2..a79ba591af9 100644 --- a/tests/e2e/ssh-orcad-idle-exit.spec.ts +++ b/tests/e2e/ssh-orcad-idle-exit.spec.ts @@ -284,6 +284,54 @@ test('a killed managed orcad comes back with its terminal, and starts fresh afte } }) +// Slim images ship no ps, and after a reboot the slot's recorded PID often names a new process (#34149). +test('after a reboot on a host without ps, a recycled server PID does not keep the server down', async (// oxlint-disable-next-line no-empty-pattern -- This test owns its isolated app and host. +{}, testInfo) => { + test.skip( + HOST !== 'docker' || !TEMPLATE_SOURCE, + `Set ${ORCAD_CONVERT_HOST_ENV}=docker and ORCA_E2E_ORCAD_CONVERT_TEMPLATE` + ) + // Deploys a real server, then reconnects after a simulated reboot. + test.setTimeout(10 * 60_000) + const target = startDockerSshHostTarget(testInfo) + const session = createRestartSession(testInfo, { ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE_SOURCE! }) + let app: ElectronApplication | null = null + try { + const launched = await session.launch() + app = launched.app + const page = launched.page + await waitForSessionReady(page) + const { targetId, environmentId } = await connectManagedHost(page, target) + await page.evaluate((targetId) => window.api.ssh.disconnect({ targetId }), targetId) + + execDockerSshHostTargetCommand(target, "pkill -KILL -f '/root/[.]orca-remote/' || true") + await expect.poll(() => runningOrcadPids(target), { timeout: 60_000 }).toEqual([]) + const marker = String(800_000 + Math.floor(Math.random() * 100_000)) + // The stale pid file now names a live process of the same user that is not orcad. + execDockerSshHostTargetCommand( + target, + `setsid sleep ${marker} /dev/null 2>&1 & ` + + 'for f in /root/.orca-remote/orcad-*/.orcad-pid; do echo $! > "$f"; done; ' + + 'mv "$(command -v ps)" /usr/local/ps-hidden' + ) + await expect.poll(() => processAlive(target, marker), { timeout: 10_000 }).toBe(true) + + const connected = JSON.parse(await reconnect(page, targetId)) + expect(connected).toMatchObject({ kind: 'managed', environmentId }) + expect(connected).not.toHaveProperty('serving') + expect(await callEnvironment(page, environmentId, 'terminal.list', {})).toMatchObject({ + ok: true + }) + expect(processAlive(target, marker)).toBe(true) + } finally { + if (app) { + await session.close(app) + } + await session.dispose() + cleanupDockerSshHostTarget(target) + } +}) + test('a new desktop on the same build starts a server that was killed with its daemon', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. {}, testInfo) => { test.skip(