mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
test: drain detached AppImage fixture writers before cleanup
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user