fix(serve): exit cleanly after headless Linux signals (#14334)

* fix(serve): keep owned Xvfb alive through Electron teardown

* test(serve): gate packaged signal shutdown

* test: harden headless shutdown lifecycle gate

* fix(serve): isolate Xvfb from foreground signals

* docs(serve): preserve Xvfb during systemd stop

* test(serve): pin shutdown policy to owned Xvfb unit

* test(serve): harden shutdown gate portability

* test(serve): bound systemd unit parsing
This commit is contained in:
Jinwoo Hong
2026-08-13 15:45:00 -07:00
committed by GitHub
parent 953cfab635
commit ff8dda81e8
9 changed files with 602 additions and 11 deletions
+4 -1
View File
@@ -383,7 +383,10 @@ jobs:
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
@@ -0,0 +1,37 @@
FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
dbus-x11 \
iproute2 \
jq \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libasound2t64 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
signal_name=${1:?signal name is required}
app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
if ((EUID == 0)); then
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
case "$signal_name" in
INT|TERM) ;;
*) echo "unsupported signal: $signal_name" >&2; exit 64 ;;
esac
state_dir=$(mktemp -d "/tmp/orca-shutdown-${signal_name}.XXXXXX")
stdout_log="$state_dir/stdout.log"
stderr_log="$state_dir/stderr.log"
ulimit -c 0
sleep 300 &
canary_pid=$!
canary_start_ticks=$(awk '{print $22}' "/proc/$canary_pid/stat")
cleanup() {
kill "$canary_pid" 2>/dev/null || true
wait "$canary_pid" 2>/dev/null || true
}
trap cleanup EXIT
export HOME="$state_dir/home"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
export XDG_RUNTIME_DIR="$state_dir/runtime"
export LIBGL_ALWAYS_SOFTWARE=1
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
case "$entrypoint_kind" in
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
launcher)
export ELECTRON_DISABLE_SANDBOX=1
entrypoint=("$app_root/resources/bin/orca-ide")
;;
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
esac
setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.0.1 --json \
>"$stdout_log" 2>"$stderr_log" &
app_pid=$!
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
# The inner shell expands its positional parameters.
# shellcheck disable=SC2016
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
' bash "$app_pid" "$stdout_log" || true)
if [[ -z "$ready_line" ]]; then
cat "$stdout_log" "$stderr_log" >&2
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
exit 1
fi
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
bound_port=${bound_endpoint##*:}
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
if [[ -z "$listener_before" ]]; then
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
exit 1
fi
tree_pids=()
declare -A tree_start_ticks
tree_start_ticks["$app_pid"]=$app_start_ticks
frontier=("$app_pid")
while ((${#frontier[@]})); do
parent=${frontier[0]}
frontier=("${frontier[@]:1}")
while read -r child; do
[[ -n "$child" ]] || continue
child_start_ticks=$(awk '{print $22}' "/proc/$child/stat" 2>/dev/null || true)
[[ -n "$child_start_ticks" ]] || continue
tree_pids+=("$child")
tree_start_ticks["$child"]=$child_start_ticks
frontier+=("$child")
done < <(ps -o pid= --ppid "$parent" | tr -d ' ')
done
tree_pid_csv="$app_pid"
for pid in "${tree_pids[@]}"; do
tree_pid_csv+=",$pid"
done
tree_snapshot=$(ps -o pid=,ppid=,pgid=,lstart=,stat=,args= -p "$tree_pid_csv" 2>/dev/null || true)
xvfb_pids=$(awk '/[X]vfb :99 / {print $1}' <<<"$tree_snapshot" | paste -sd, -)
if [[ -z "$xvfb_pids" ]]; then
echo "$tree_snapshot" >&2
echo "FAIL: no run-owned Xvfb :99 process found after readiness" >&2
exit 1
fi
signal_target_pid=$app_pid
if [[ "$signal_target_kind" == serving-electron ]]; then
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
elif [[ "$signal_target_kind" != app ]]; then
echo "unsupported signal target: $signal_target_kind" >&2
exit 64
fi
signal_target_start_ticks=${tree_start_ticks[$signal_target_pid]:-}
if [[ -z "$signal_target_start_ticks" ]] \
|| [[ $(awk '{print $22}' "/proc/$signal_target_pid/stat") != "$signal_target_start_ticks" ]]; then
echo "FAIL: signal target identity changed before delivery" >&2
exit 1
fi
signal_delivery=pid
if [[ "$signal_name" == INT && "$int_delivery" == foreground-process-group ]]; then
signal_delivery=$int_delivery
kill -s "$signal_name" -- "-$signal_target_pid"
else
kill -s "$signal_name" "$signal_target_pid"
fi
sleep 30 &
watchdog_pid=$!
set +e
wait -n -p completed_pid "$app_pid" "$watchdog_pid"
wait_status=$?
set -e
if [[ "$completed_pid" == "$watchdog_pid" ]]; then
echo "FAIL: foreground AppRun did not exit after $signal_name" >&2
exit 1
fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
survivors=()
for pid in "${tree_pids[@]}"; do
if [[ -r "/proc/$pid/stat" ]] \
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
survivors+=("$pid")
fi
done
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
canary_alive=false
if kill -0 "$canary_pid" 2>/dev/null \
&& [[ $(awk '{print $22}' "/proc/$canary_pid/stat") == "$canary_start_ticks" ]]; then
canary_alive=true
fi
fatal_evidence=false
if grep -Eq 'Failed to shutdown|SIGTRAP|Trace/breakpoint trap|core dumped' \
"$stdout_log" "$stderr_log"; then
fatal_evidence=true
fi
jq -nc \
--arg signal "$signal_name" \
--arg signalDelivery "$signal_delivery" \
--arg entrypointKind "$entrypoint_kind" \
--arg signalTargetKind "$signal_target_kind" \
--argjson appPid "$app_pid" \
--argjson signalTargetPid "$signal_target_pid" \
--arg endpoint "$bound_endpoint" \
--arg listenerBefore "$listener_before" \
--arg listenerAfter "$listener_after" \
--arg xvfbPids "$xvfb_pids" \
--arg treeBefore "$tree_snapshot" \
--argjson waitStatus "$wait_status" \
--argjson fatalEvidence "$fatal_evidence" \
--argjson canaryAlive "$canary_alive" \
--arg survivors "${survivors[*]:-}" \
--arg residue "$owned_residue" \
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
echo "--- stdout ---" >&2
cat "$stdout_log" >&2
echo "--- stderr ---" >&2
cat "$stderr_log" >&2
exit 1
fi
+116
View File
@@ -11665,6 +11665,122 @@
],
"demotionRule": "Demote if any exit duration can evade the spawn budget, concurrent children or timers appear, cleanup retains resources, or a transient failure cannot recover within the budget."
},
{
"id": "runtime.headless-serve-graceful-signal-exit",
"title": "Packaged headless Linux serve exits cleanly after foreground signals",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "electron-headless-quit-lifecycle",
"surfaces": ["packaged Linux AppImage", "headless orca serve", "owned Xvfb lifecycle"],
"platforms": ["linux"],
"providers": ["local-daemon"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local-daemon"],
"coverageNotes": "An Ubuntu 26.04 amd64 container extracts the packaged AppImage into disposable HOME and XDG directories, leaves APPDIR unset to preserve extracted-AppRun direct serve mode, waits for structured serve readiness, then exercises terminal-style foreground-process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM in separate containers. Local evidence runs under Rosetta on an arm64 Docker host; native amd64 PR CI repeats the same foreground AppRun identity contract.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/14109",
"https://linear.app/stably/issue/STA-4051"
],
"invariant": "After packaged foreground headless serve publishes structured readiness, one SIGINT or SIGTERM exits successfully without an Electron fatal trap or core evidence, releases the exact listener and owned Xvfb/process tree, and leaves an unrelated process identity untouched.",
"oracle": "For each signal, start a fresh unprivileged Ubuntu 26.04 container with disposable profile and runtime directories, a random loopback port, DISPLAY unset, software GL, and the extracted AppImage in a fresh session. Wait for orca_server_ready schema version 1, record the listener owner, process tree, owned Xvfb, and unrelated canary identities, deliver SIGINT to the foreground process group or the documented KillMode=mixed graceful SIGTERM to the AppRun PID, then require wait status zero, no Failed to shutdown, SIGTRAP, core, listener, recorded descendant, profile/AppImage/Xvfb residue, or changed canary identity. The 30-second bounds are failure deadlines, never success conditions.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"shellcheck config/docker/headless-serve-shutdown/run-signal-case.sh",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage",
"node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64"
],
"testFiles": [
"src/main/startup/ensure-virtual-display.test.ts",
"config/scripts/headless-serve-shutdown-workflow.test.mjs",
"config/scripts/run-headless-serve-shutdown-docker.mjs",
"config/docker/headless-serve-shutdown/run-signal-case.sh"
],
"assertionRefs": [
{
"file": "src/main/startup/ensure-virtual-display.test.ts",
"assertions": [
"owned Xvfb starts with terminate-after-last-client semantics",
"owned Xvfb uses an independent process group so terminal Ctrl-C cannot preempt Electron teardown",
"owned Xvfb retains an early-process-exit guard until Electron is ready",
"owned Xvfb is not stopped from Electron's cancelable will-quit event",
"external displays and non-Linux startup remain untouched"
]
},
{
"file": "config/scripts/headless-serve-shutdown-workflow.test.mjs",
"assertions": [
"PR CI builds an x64 AppImage before invoking the packaged shutdown oracle",
"the documented systemd unit uses KillMode=mixed so graceful TERM targets Orca before its owned Xvfb"
]
},
{
"file": "config/scripts/run-headless-serve-shutdown-docker.mjs",
"assertions": [
"SIGINT and SIGTERM run in separate disposable containers",
"both signal failures are reported before the oracle exits",
"the exact AppImage SHA-256, entrypoint, and signal target are published",
"the launcher exec overlay isolates the related STA-4017 signal boundary"
]
},
{
"file": "config/docker/headless-serve-shutdown/run-signal-case.sh",
"assertions": [
"SIGINT reaches the isolated foreground process group while owned Xvfb stays in its own group",
"SIGTERM reaches the exact AppRun PID under the documented systemd KillMode=mixed policy",
"target and descendant identities are fenced by PID start ticks before signaling and residue checks"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-13",
"runner": "local",
"platform": "linux",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/ensure-virtual-display.test.ts config/scripts/headless-serve-shutdown-workflow.test.mjs --reporter=dot",
"result": "passed",
"durationSeconds": 1,
"summary": "The focused startup and workflow contracts passed with owned-display termination semantics and native amd64 CI wiring."
},
{
"date": "2026-08-13",
"runner": "local",
"platform": "linux",
"command": "node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage --platform linux/amd64",
"result": "passed",
"durationSeconds": 48,
"summary": "The extracted candidate AppRun passed process-group SIGINT and systemd-mixed main-PID SIGTERM under Ubuntu 26.04 amd64 emulation with status zero, no fatal evidence, full listener/Xvfb/tree cleanup, and an unchanged canary identity."
}
],
"runtimeBudget": {
"p95Seconds": 240,
"scope": "two fresh Ubuntu 26.04 containers, one per foreground signal"
},
"flakeHistory": {
"status": "not-started",
"evidence": "The event- and identity-driven harness is new; soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical Docker oracle failed process-group SIGINT and main-PID SIGTERM on v1.4.180 AppImage af3b6e6a67fc, launch-day main AppImage 017ff5abc35a, and candidate-with-fix-disabled AppImage f409f58dd9c3 with wait status 133 plus Electron Failed to shutdown and SIGTRAP. The prior candidate 5b94b86a9879 passed PID signals but failed process-group SIGINT with status 133, proving Xvfb also needed an independent process group. Final candidate AppImage 5c82936043e4 passed process-group SIGINT and the documented systemd KillMode=mixed main-PID SIGTERM with status zero and complete cleanup. A control-group TERM that also targets Xvfb remains red, proving KillMode=mixed is required for the documented owned-Xvfb unit. Applying PR #14071's launcher exec semantics with PID delivery left v1.4.180 red and the candidate green, proving the launcher and Electron/Xvfb fixes are independent and composable."
},
"performanceBudget": {
"required": true,
"evidence": "The product change adds one standard Xvfb startup flag, isolates the existing Xvfb child from foreground process-group signals, replaces one Electron lifecycle listener with a process listener that is removed at ready, and adds no polling, timer, subprocess, IPC, provider fanout, renderer work, retained payload, native dependency, or recurring hot-path work. After ready, Xvfb exits from its existing client-disconnect path."
},
"promotionCriteria": [
"Collect 100 consecutive native amd64 CI or soak passes or 14 days without an unexplained flake.",
"Collect one native Ubuntu 26.04 AppRun signal run with foreground wait status zero for both signals.",
"Keep exact listener, Xvfb, descendant, fatal-evidence, and canary identity assertions green."
],
"knownGaps": [
"The local arm64 Docker host uses Rosetta for amd64 containers; native amd64 PR CI supplies the non-emulated repeat of the same extracted-AppRun PID contract.",
"Ubuntu 20.04 is covered by the packaged native-binary glibc floor gate; this lifecycle journey runs on the reported Ubuntu 26.04 topology.",
"Systemd units that override or omit the documented KillMode=mixed policy can still terminate owned Xvfb before Electron disconnects.",
"The harness exercises foreground headless Linux shutdown and does not replace desktop, updater, SSH, or detached PTY lifecycle coverage."
],
"demotionRule": "Keep experimental or demote if either signal traps, returns nonzero, retains its listener/Xvfb/run-owned process identity, touches the unrelated canary, or the focused gate flakes without an identified product or harness defect."
},
{
"id": "ssh-managed-hooks.node18-runtime-compatibility",
"title": "SSH managed-hook companions load and install hooks on Node 18",
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map(
(match) => {
const start = match.index + match[0].length
const end = doc.indexOf('```', start)
const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m)
const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset
if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) {
throw new Error(`Missing closing code fence for ${unitName}`)
}
return doc.slice(start, end)
}
)
}
describe('headless serve shutdown PR gate', () => {
it('reads only exact, closed systemd unit blocks', () => {
expect(
readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service')
).toEqual([])
expect(() =>
readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service')
).toThrow('Missing closing code fence for orca-serve.service')
expect(() =>
readSystemdUnitBlocks(
'# /etc/systemd/system/orca-serve.service\n' +
'KillMode=mixed\n' +
'# /etc/systemd/system/other.service\n```',
'orca-serve.service'
)
).toThrow('Missing closing code fence for orca-serve.service')
})
it('packages an x64 AppImage before running the Docker signal oracle', () => {
const steps = workflow.jobs.package.steps
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
)
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
})
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service')
const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit))
const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit))
expect(ownedXvfbUnits).toHaveLength(1)
expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m)
expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m)
expect(managedXvfbUnits).toHaveLength(1)
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
})
})
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const args = process.argv.slice(2)
const appImageArg = valueAfter('--appimage')
const platform = valueAfter('--platform') ?? 'linux/amd64'
const signalTarget = valueAfter('--signal-target') ?? 'app'
const entrypoint = valueAfter('--entrypoint') ?? 'app'
const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group'
const launcherExecOverlay = args.includes('--launcher-exec-overlay')
if (!appImageArg) {
fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage')
}
if (!['app', 'serving-electron'].includes(signalTarget)) {
fail(`Unsupported --signal-target: ${signalTarget}`)
}
if (!['app', 'launcher'].includes(entrypoint)) {
fail(`Unsupported --entrypoint: ${entrypoint}`)
}
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
fail(`Unsupported --int-delivery: ${intDelivery}`)
}
if (intDelivery === 'foreground-process-group' && signalTarget !== 'app') {
fail('--int-delivery foreground-process-group requires --signal-target app')
}
if (launcherExecOverlay && entrypoint !== 'launcher') {
fail('--launcher-exec-overlay requires --entrypoint launcher')
}
const appImage = resolve(appImageArg)
const shutdownDockerDirectory = resolve('config', 'docker', 'headless-serve-shutdown')
const shutdownDockerfile = resolve(shutdownDockerDirectory, 'Dockerfile')
if (!existsSync(appImage)) {
fail(`AppImage not found: ${appImage}`)
}
const suffix = `${process.pid}-${Date.now()}`
const image = `orca-headless-serve-shutdown:${suffix}`
const artifactVolume = `orca-headless-serve-shutdown-${suffix}`
const sha256 = createHash('sha256').update(readFileSync(appImage)).digest('hex')
try {
docker([
'build',
'--platform',
platform,
'-f',
shutdownDockerfile,
'-t',
image,
shutdownDockerDirectory
])
docker(['volume', 'create', artifactVolume])
docker([
'run',
'--rm',
'--platform',
platform,
'--entrypoint',
'bash',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts`,
image,
'-lc',
[
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
launcherExecOverlay
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
: ':',
'chmod -R a+rX /artifacts/root'
].join(' && ')
])
console.log(
JSON.stringify({
type: 'appimage_under_test',
appImage,
sha256,
platform,
signalTarget,
entrypoint,
intDelivery,
launcherExecOverlay
})
)
const failedSignals = []
for (const signal of ['INT', 'TERM']) {
const result = docker(
[
'run',
'--rm',
'--init',
'--platform',
platform,
'--shm-size',
'256m',
'--name',
`orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`,
'-e',
`ORCA_SIGNAL_TARGET=${signalTarget}`,
'-e',
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
],
{ allowFailure: true }
)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status !== 0) {
failedSignals.push(`${signal}:${result.status}`)
}
}
if (failedSignals.length > 0) {
fail(`Shutdown oracle failed: ${failedSignals.join(', ')}`)
}
console.log('Headless serve packaged shutdown Docker validation passed.')
} finally {
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
docker(['image', 'rm', image], { allowFailure: true })
}
function valueAfter(flag) {
const index = args.indexOf(flag)
return index === -1 ? null : (args[index + 1] ?? null)
}
function docker(dockerArgs, options = {}) {
const result = spawnSync('docker', dockerArgs, {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.error) {
throw result.error
}
if (result.status !== 0 && !options.allowFailure) {
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
fail(`docker ${dockerArgs[0]} failed with status ${result.status}`)
}
return result
}
function fail(message) {
console.error(message)
process.exitCode = 1
throw new Error(message)
}
+5
View File
@@ -166,6 +166,7 @@ Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
StandardOutput=journal
StandardError=journal
KillMode=mixed
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
@@ -177,6 +178,10 @@ WantedBy=multi-user.target
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
`KillMode=mixed` sends the graceful stop signal only to Orca's main process,
then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out.
This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly.
Exit status `3` means another process already owns this userData profile, so
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
cannot succeed. Any other permanent startup fault is capped at 5 starts per
@@ -42,7 +42,11 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
delete process.env.DISPLAY
})
afterEach(() => {
afterEach(async () => {
const { stopVirtualDisplay } = await import('./ensure-virtual-display')
process.removeListener('exit', stopVirtualDisplay)
stopVirtualDisplay()
vi.restoreAllMocks()
setPlatform(ORIGINAL_PLATFORM)
if (ORIGINAL_DISPLAY === undefined) {
delete process.env.DISPLAY
@@ -97,18 +101,27 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
// First existsSync (stale-socket check) false; later (socket-ready poll) true.
existsSyncMock.mockReturnValueOnce(false).mockReturnValue(true)
spawnMock.mockReturnValue({ once: vi.fn(), kill: vi.fn(), killed: false })
const { ensureVirtualDisplayForHeadlessServe } = await import('./ensure-virtual-display')
const processOnceSpy = vi.spyOn(process, 'once')
const processRemoveListenerSpy = vi.spyOn(process, 'removeListener')
const { ensureVirtualDisplayForHeadlessServe, stopVirtualDisplay } =
await import('./ensure-virtual-display')
expect(ensureVirtualDisplayForHeadlessServe({ isServeMode: true })).toBe(true)
expect(spawnMock).toHaveBeenCalledWith(
'Xvfb',
expect.arrayContaining([':99']),
expect.anything()
expect.arrayContaining([':99', '-terminate']),
expect.objectContaining({ detached: true })
)
expect(process.env.DISPLAY).toBe(':99')
expect(appMock.disableHardwareAcceleration).toHaveBeenCalled()
expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-dev-shm-usage')
expect(appMock.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu')
expect(processOnceSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay)
const readyHandler = appMock.once.mock.calls.find(([event]) => event === 'ready')?.[1]
expect(readyHandler).toBeTypeOf('function')
readyHandler()
expect(processRemoveListenerSpy).toHaveBeenCalledWith('exit', stopVirtualDisplay)
expect(appMock.once.mock.calls.some(([event]) => event === 'will-quit')).toBe(false)
})
it('reuses an existing virtual display only when its X server is alive', async () => {
@@ -144,8 +157,8 @@ describe('ensureVirtualDisplayForHeadlessServe', () => {
expect(rmSyncMock).toHaveBeenCalled()
expect(spawnMock).toHaveBeenCalledWith(
'Xvfb',
expect.arrayContaining([':99']),
expect.anything()
expect.arrayContaining([':99', '-terminate']),
expect.objectContaining({ detached: true })
)
expect(process.env.DISPLAY).toBe(':99')
killSpy.mockRestore()
+6 -4
View File
@@ -137,10 +137,11 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo
try {
xvfbProcess = spawn(
'Xvfb',
[VIRTUAL_DISPLAY, '-screen', '0', '1280x1024x24', '-nolisten', 'tcp'],
[VIRTUAL_DISPLAY, '-screen', '0', '1280x1024x24', '-nolisten', 'tcp', '-terminate'],
{
stdio: 'ignore',
detached: false
// Why: foreground Ctrl-C must not kill Xvfb before Electron disconnects.
detached: true
}
)
xvfbProcess.once('error', (error) => {
@@ -163,8 +164,9 @@ export function ensureVirtualDisplayForHeadlessServe(options: { isServeMode: boo
process.env.DISPLAY = VIRTUAL_DISPLAY
// Why: don't leave a stray Xvfb process behind when serve exits.
app.once('will-quit', stopVirtualDisplay)
// Why: -terminate only takes effect after Xvfb accepts its first client.
process.once('exit', stopVirtualDisplay)
app.once('ready', () => process.removeListener('exit', stopVirtualDisplay))
return true
}