mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ssh): start a stopped managed server whose recycled PID hid it on hosts without ps (#34519)
* fix(ssh): prove a recycled server PID exited on hosts without ps On slim Linux images that ship no ps, the managed server's liveness probe could not read the recorded PID's argv, so after a reboot a PID that now named another process read as LIVE (same user) or UNKNOWN (root). The wake then refused to start the stopped server, the update deferred on the terminal census, and the host stayed unreachable. Read argv and the zombie state from /proc when ps is missing. Fixes #34149 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(ssh): name argv-less PIDs from /proc stat; annotate e2e timeout Kernel threads and zombies have an empty cmdline, so a recycled PID owned by one stayed UNKNOWN on hosts without ps. Quote the stat pattern so the probe also parses inside command substitution on older bash. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * fix(ssh): fall back to the stat line only after cmdline was read A failed tr must stay unproven rather than make a live server's PID look recycled. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<pid>/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"; };'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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 >/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(
|
||||
|
||||
Reference in New Issue
Block a user