diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4af2ea02b8d..ceb68feaa17 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/config/docker/headless-serve-shutdown/Dockerfile b/config/docker/headless-serve-shutdown/Dockerfile new file mode 100644 index 00000000000..669bb02b00a --- /dev/null +++ b/config/docker/headless-serve-shutdown/Dockerfile @@ -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"] diff --git a/config/docker/headless-serve-shutdown/run-signal-case.sh b/config/docker/headless-serve-shutdown/run-signal-case.sh new file mode 100755 index 00000000000..3cbf594ae1e --- /dev/null +++ b/config/docker/headless-serve-shutdown/run-signal-case.sh @@ -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 diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 06a3a927e86..e85b8603ff9 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -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", diff --git a/config/scripts/headless-serve-shutdown-workflow.test.mjs b/config/scripts/headless-serve-shutdown-workflow.test.mjs new file mode 100644 index 00000000000..6590596e41c --- /dev/null +++ b/config/scripts/headless-serve-shutdown-workflow.test.mjs @@ -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) + }) +}) diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs new file mode 100755 index 00000000000..f3852624854 --- /dev/null +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -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) +} diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 2284bcdaa23..0e07c06ebf3 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -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 diff --git a/src/main/startup/ensure-virtual-display.test.ts b/src/main/startup/ensure-virtual-display.test.ts index f66d79fefea..93f4bb14f77 100644 --- a/src/main/startup/ensure-virtual-display.test.ts +++ b/src/main/startup/ensure-virtual-display.test.ts @@ -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() diff --git a/src/main/startup/ensure-virtual-display.ts b/src/main/startup/ensure-virtual-display.ts index a4adfed6113..a9d517f9f99 100644 --- a/src/main/startup/ensure-virtual-display.ts +++ b/src/main/startup/ensure-virtual-display.ts @@ -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 }