mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user