From fa5ccd84f866d5581b888fc2121dfe5ef2ebe892 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 28 Sep 2026 07:29:49 -0700 Subject: [PATCH] test: drain detached AppImage fixture writers before cleanup --- .../run-appimage-desktop-startup-case.sh | 41 ++++++- .../scripts/appimage-startup-cleanup.test.mjs | 107 ++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 config/scripts/appimage-startup-cleanup.test.mjs diff --git a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh index 59a6bef0e5c..291b60653fd 100755 --- a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh +++ b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh @@ -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 diff --git a/config/scripts/appimage-startup-cleanup.test.mjs b/config/scripts/appimage-startup-cleanup.test.mjs new file mode 100644 index 00000000000..fc709686714 --- /dev/null +++ b/config/scripts/appimage-startup-cleanup.test.mjs @@ -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 }) + } + }) +})