test: drain detached AppImage fixture writers before cleanup

This commit is contained in:
m4air
2026-09-28 07:29:49 -07:00
parent 3581de644a
commit fa5ccd84f8
2 changed files with 143 additions and 5 deletions
@@ -47,7 +47,10 @@ declare -A tree_start_ticks=()
read_start_ticks() {
local pid=$1
[[ -r "/proc/$pid/stat" ]] || return 1
awk '{print $22}' "/proc/$pid/stat"
local stat
stat=$(cat "/proc/$pid/stat") || return 1
# comm is parenthesized and may contain spaces or parentheses.
awk '{print $20}' <<< "${stat##*) }"
}
identity_alive() {
@@ -55,7 +58,7 @@ identity_alive() {
local expected_ticks=$2
[[ -n "$expected_ticks" ]] || return 1
[[ -r "/proc/$pid/stat" ]] || return 1
[[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "$expected_ticks" ]] || return 1
[[ $(read_start_ticks "$pid" 2>/dev/null || true) == "$expected_ticks" ]] || return 1
local process_state
process_state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)
[[ -n "$process_state" && "$process_state" != Z* ]]
@@ -85,6 +88,27 @@ collect_process_tree() {
done
}
# Detached services can outlive every ancestor visible in the startup tree.
collect_state_processes() {
local signal=$1 stat_file pid ticks
for stat_file in /proc/[0-9]*/stat; do
pid=${stat_file#/proc/}
pid=${pid%/stat}
[[ "$pid" != "$$" && -O "$stat_file" ]] || continue
ticks=$(read_start_ticks "$pid" 2>/dev/null || true)
[[ -n "$ticks" ]] || continue
if ! grep -zFxq -- "HOME=$state_dir/home" "/proc/$pid/environ" 2>/dev/null; then
continue
fi
identity_alive "$pid" "$ticks" || continue
if [[ "${tree_start_ticks[$pid]-}" != "$ticks" ]]; then
tree_pids+=("$pid")
tree_start_ticks["$pid"]=$ticks
kill -s "$signal" "$pid" 2>/dev/null || true
fi
done
}
process_is_xvfb() {
local pid=$1
local command_name
@@ -117,9 +141,12 @@ signal_owned_processes() {
wait_for_owned_exit() {
local timeout_seconds=$1
local signal=$2
local empty_passes=0
local deadline=$((SECONDS + timeout_seconds))
local pid ticks alive
while ((SECONDS < deadline)); do
collect_state_processes "$signal"
alive=0
for pid in "${tree_pids[@]}"; do
ticks=${tree_start_ticks[$pid]-}
@@ -129,7 +156,10 @@ wait_for_owned_exit() {
fi
done
if ((alive == 0)); then
return 0
empty_passes=$((empty_passes + 1))
((empty_passes < 2)) || return 0
else
empty_passes=0
fi
sleep 0.2
done
@@ -178,12 +208,13 @@ report_launcher_exit() {
cleanup() {
local status=$?
trap - EXIT
collect_state_processes TERM
signal_process_group TERM || true
signal_owned_processes TERM || true
if ! wait_for_owned_exit 10; then
if ! wait_for_owned_exit 10 TERM; then
signal_process_group KILL || true
signal_owned_processes KILL || true
wait_for_owned_exit 5 || status=1
wait_for_owned_exit 5 KILL || status=1
fi
capture_launcher_status || true
if ((status != 0)); then
@@ -0,0 +1,107 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { spawnProcess, runProcess } from '../../src/shared/child-process/run-process'
const oracle = readFileSync(
'config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh',
'utf8'
)
const functions = oracle.slice(oracle.indexOf('read_start_ticks()'), oracle.indexOf('dump_logs()'))
function isRunning(pid) {
try {
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1][0] !== 'Z'
} catch {
return false
}
}
async function waitFor(predicate) {
const deadline = Date.now() + 5000
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error('Fixture readiness timed out')
}
await new Promise((resolve) => setTimeout(resolve, 20))
}
}
describe.skipIf(process.platform !== 'linux')('AppImage startup process cleanup', () => {
it('drains detached profile writers born during shutdown without touching a neighboring HOME', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-startup-cleanup-test-'))
const profileHome = join(directory, 'home')
mkdirSync(profileHome)
const successorPidFile = join(directory, 'successor.pid')
const readyFile = join(directory, 'ready')
const writerCode = `
const fs = require('node:fs');
process.title = 'orca writer (x)';
setInterval(() => fs.writeFileSync(process.env.HOME + '/state', 'alive'), 5);
process.on('SIGTERM', () => {
const child = require('node:child_process').spawn(process.execPath, ['-e',
"setInterval(() => require('node:fs').writeFileSync(process.env.HOME + '/late-state', 'alive'), 5)"
], { detached: true, stdio: 'ignore', env: process.env });
fs.writeFileSync(${JSON.stringify(successorPidFile)}, String(child.pid));
child.unref();
process.exit(0);
});
fs.writeFileSync(${JSON.stringify(readyFile)}, 'ready');
`
const writer = spawnProcess({
program: process.execPath,
args: ['-e', writerCode],
detached: true,
env: { ...process.env, HOME: profileHome },
stdio: 'ignore'
})
const canary = spawnProcess({
program: process.execPath,
args: ['-e', 'setInterval(() => {}, 1000)'],
detached: true,
env: { ...process.env, HOME: `${profileHome}-unrelated` },
stdio: 'ignore'
})
try {
await waitFor(() => {
try {
return readFileSync(readyFile, 'utf8') === 'ready'
} catch {
return false
}
})
const result = await runProcess({
program: 'bash',
args: [
'-c',
`
set -euo pipefail
state_dir=$1
tree_pids=()
declare -A tree_start_ticks=()
${functions}
collect_state_processes TERM
wait_for_owned_exit 5 TERM
`,
'cleanup-fixture',
directory
],
timeoutMs: 10_000
})
expect(result.code, result.stderr).toBe(0)
expect(result.timedOut).toBe(false)
expect(isRunning(writer.pid)).toBe(false)
expect(isRunning(Number(readFileSync(successorPidFile, 'utf8')))).toBe(false)
expect(isRunning(canary.pid)).toBe(true)
} finally {
writer.kill('SIGKILL')
canary.kill('SIGKILL')
try {
process.kill(Number(readFileSync(successorPidFile, 'utf8')), 'SIGKILL')
} catch {}
await waitFor(() => !isRunning(writer.pid) && !isRunning(canary.pid))
rmSync(directory, { recursive: true, force: true })
}
})
})