Files
php-qml/examples/todo/tests/bundled-supervisor.sh

242 lines
11 KiB
Bash
Raw Normal View History

test: bundled-mode supervisor integration test (faked AppImage layout) Stages a fake AppImage layout in /tmp without a real .AppImage build: $ROOT/usr/bin/<app> — copy of the host binary $ROOT/usr/bin/frankenphp — symlink to system frankenphp $ROOT/usr/share/<app>/symfony — staged --no-dev composer copy $ROOT/usr/share/<app>/Caddyfile The staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache/log redirect (Kernel::getCacheDir + APP_CACHE_DIR override) — without the override, Symfony would fail to mkdir var/cache/prod and migrations would error out. Then runs the host with BRIDGE_URL unset (forces bundled mode), polls /healthz, and asserts: - status=ok + bundle="PhpQml\Bridge\Publisher" — proves the HealthController deep-load (predecessor commit) actually autowired Publisher, i.e. BridgeBundle is reachable. - User data dir's var/cache exists — APP_CACHE_DIR override fired. - Staged tree's var/cache/prod is empty — Symfony didn't write into the read-only mount. Together this catches every v0.1.0 shakedown bug in CI: - doubled bin/frankenphp path (resolveFrankenphpBin) - composer path-repo symlink dangling (staging-symfony's symlink:false sed) - read-only mount cache failure (Kernel + supervisor env-vars) - bundle autoload broken (HealthController canary) Implementation gotcha (caught during dev): the host binary must be COPIED into the staged layout, not symlinked. Qt's applicationDirPath() reads /proc/self/exe which dereferences symlinks, so a symlinked host would resolve to the original build/ dir and the supervisor would hunt for frankenphp + symfony there instead of the staged tree. Real AppImages copy the binary, mimicking that here. Wiring: - examples/todo/Makefile: extracted the staging-symfony logic out of the appimage target into its own staging-symfony target. New integration-bundled target depends on `build` + `staging-symfony` and runs tests/bundled-supervisor.sh. quality target now invokes integration-bundled after the existing dev-mode integration test. - .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration test" step right after the dev-mode integration step. Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked in PLAN.md §13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:36:21 +02:00
#!/usr/bin/env bash
# Bundled-mode integration test (v0.1.1).
#
# Exercises the bundled-mode supervisor codepath end-to-end *without*
# requiring a real AppImage build:
#
# - resolveFrankenphpBin (BackendConnection.cpp) — finds frankenphp
# as a sibling of the host binary at usr/bin/frankenphp.
# - resolveSymfonyDir / resolveCaddyfilePath — finds the staged
# Symfony tree + Caddyfile under usr/share/<app>/.
# - runMigrations + spawnChild — supervisor drives the doctrine
# migrate, spawns frankenphp, polls /healthz.
# - Kernel::getCacheDir / getLogDir override — Symfony writes to
# the user data dir, not the (chmod -w) staged tree.
# - HealthController deep-load — /healthz response includes a
# `bundle` field proving BridgeBundle was autoloaded.
#
# Catches the v0.1.0 shakedown bugs (doubled bin/frankenphp path,
# composer path-repo symlink dangling at runtime, read-only mount
# var/cache failure) faster than perfsmoke against a real .AppImage.
#
# Designed for `make integration-bundled`. Expects the regular
# `make build` artefacts to exist; runs `make staging-symfony`
# itself if the staged tree isn't present.
#
# Skip-conditions:
# - port 8765 already in use (don't trample a dev instance)
# - frankenphp not on PATH
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BUILD_DIR="$APP_DIR/build/qml"
HOST_BIN="$BUILD_DIR/todo"
STAGING="$APP_DIR/build/staging-symfony"
CADDYFILE="$APP_DIR/Caddyfile"
APP_NAME=todo
PORT=8765
step() { echo "$*"; }
fail() { echo "✗ FAIL: $*" >&2; exit 1; }
skip() { echo "⊘ SKIP: $*" >&2; exit 0; }
# ── Pre-flight ─────────────────────────────────────────────────────────
[ -x "$HOST_BIN" ] || fail "host binary not built — run 'make build' first ($HOST_BIN)"
command -v frankenphp >/dev/null 2>&1 || skip "frankenphp not on PATH"
[ -d "$STAGING" ] || { step "no staging-symfony, building it"; (cd "$APP_DIR" && make staging-symfony >/dev/null); }
if (echo > "/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
skip "port $PORT already in use (dev instance running?)"
fi
# ── Stage a fake AppImage layout in a temp dir ─────────────────────────
ROOT="$(mktemp -d)"
DATA_DIR="$(mktemp -d)"
trap 'cleanup' EXIT INT TERM
PID=""
cleanup() {
trap - EXIT INT TERM
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
kill -TERM "$PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$PID" 2>/dev/null || break
sleep 0.2
done
kill -KILL "$PID" 2>/dev/null || true
fi
# Restore writable so rm -rf doesn't choke.
[ -d "$ROOT/usr/share/$APP_NAME/symfony" ] && \
chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null || true
rm -rf "$ROOT" "$DATA_DIR"
}
step "stage AppImage layout at $ROOT"
mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME"
# Host binary must be copied, not symlinked: Qt's applicationDirPath()
# reads /proc/self/exe which dereferences symlinks, so a symlinked host
# would resolve to the build/ dir and the supervisor would look for
# frankenphp + symfony there instead of in the staged layout. Real
# AppImages copy the binary, mimicking that here.
cp "$HOST_BIN" "$ROOT/usr/bin/$APP_NAME"
ln -s "$(command -v frankenphp)" "$ROOT/usr/bin/frankenphp"
cp -a "$STAGING/." "$ROOT/usr/share/$APP_NAME/symfony/"
cp "$CADDYFILE" "$ROOT/usr/share/$APP_NAME/Caddyfile"
# Make the staged Symfony tree read-only so the cache/log redirect is
# actually exercised — without the Kernel::getCacheDir/getLogDir override,
# Symfony tries to mkdir var/cache here and fails.
chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony"
# ── Launch the host ────────────────────────────────────────────────────
step "launch host (bundled mode, offscreen, isolated XDG dirs)"
LOG="$DATA_DIR/host.log"
env -u BRIDGE_URL \
XDG_DATA_HOME="$DATA_DIR/share" \
XDG_CACHE_HOME="$DATA_DIR/cache" \
XDG_CONFIG_HOME="$DATA_DIR/config" \
QT_QPA_PLATFORM=offscreen \
"$ROOT/usr/bin/$APP_NAME" > "$LOG" 2>&1 &
PID=$!
# ── Poll /healthz ──────────────────────────────────────────────────────
step "wait for /healthz"
DEADLINE=$(( $(date +%s) + 30 ))
HEALTHZ_BODY=""
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if ! kill -0 "$PID" 2>/dev/null; then
sed 's/^/ /' "$LOG" >&2 || true
fail "host died during boot"
fi
if HEALTHZ_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then
break
fi
sleep 0.2
done
[ -n "$HEALTHZ_BODY" ] || { sed 's/^/ /' "$LOG" >&2 || true; fail "/healthz never responded within 30s"; }
# ── Verify bundle deep-load ────────────────────────────────────────────
step "/healthz body: $HEALTHZ_BODY"
echo "$HEALTHZ_BODY" | grep -q '"status":"ok"' \
|| fail "/healthz didn't return status:ok"
echo "$HEALTHZ_BODY" | grep -q '"bundle":"PhpQml\\\\Bridge\\\\BridgeBundle"' \
test: bundled-mode supervisor integration test (faked AppImage layout) Stages a fake AppImage layout in /tmp without a real .AppImage build: $ROOT/usr/bin/<app> — copy of the host binary $ROOT/usr/bin/frankenphp — symlink to system frankenphp $ROOT/usr/share/<app>/symfony — staged --no-dev composer copy $ROOT/usr/share/<app>/Caddyfile The staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache/log redirect (Kernel::getCacheDir + APP_CACHE_DIR override) — without the override, Symfony would fail to mkdir var/cache/prod and migrations would error out. Then runs the host with BRIDGE_URL unset (forces bundled mode), polls /healthz, and asserts: - status=ok + bundle="PhpQml\Bridge\Publisher" — proves the HealthController deep-load (predecessor commit) actually autowired Publisher, i.e. BridgeBundle is reachable. - User data dir's var/cache exists — APP_CACHE_DIR override fired. - Staged tree's var/cache/prod is empty — Symfony didn't write into the read-only mount. Together this catches every v0.1.0 shakedown bug in CI: - doubled bin/frankenphp path (resolveFrankenphpBin) - composer path-repo symlink dangling (staging-symfony's symlink:false sed) - read-only mount cache failure (Kernel + supervisor env-vars) - bundle autoload broken (HealthController canary) Implementation gotcha (caught during dev): the host binary must be COPIED into the staged layout, not symlinked. Qt's applicationDirPath() reads /proc/self/exe which dereferences symlinks, so a symlinked host would resolve to the original build/ dir and the supervisor would hunt for frankenphp + symfony there instead of the staged tree. Real AppImages copy the binary, mimicking that here. Wiring: - examples/todo/Makefile: extracted the staging-symfony logic out of the appimage target into its own staging-symfony target. New integration-bundled target depends on `build` + `staging-symfony` and runs tests/bundled-supervisor.sh. quality target now invokes integration-bundled after the existing dev-mode integration test. - .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration test" step right after the dev-mode integration step. Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked in PLAN.md §13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:36:21 +02:00
|| fail "/healthz missing bundle field — HealthController deep-load broken"
echo "$HEALTHZ_BODY" | grep -q '"name":"php-qml\\/bridge"' \
|| fail "/healthz missing name field — BridgeBundleInfo not wired"
test: bundled-mode supervisor integration test (faked AppImage layout) Stages a fake AppImage layout in /tmp without a real .AppImage build: $ROOT/usr/bin/<app> — copy of the host binary $ROOT/usr/bin/frankenphp — symlink to system frankenphp $ROOT/usr/share/<app>/symfony — staged --no-dev composer copy $ROOT/usr/share/<app>/Caddyfile The staged Symfony tree is `chmod -R a-w` to actually exercise the read-only-mount cache/log redirect (Kernel::getCacheDir + APP_CACHE_DIR override) — without the override, Symfony would fail to mkdir var/cache/prod and migrations would error out. Then runs the host with BRIDGE_URL unset (forces bundled mode), polls /healthz, and asserts: - status=ok + bundle="PhpQml\Bridge\Publisher" — proves the HealthController deep-load (predecessor commit) actually autowired Publisher, i.e. BridgeBundle is reachable. - User data dir's var/cache exists — APP_CACHE_DIR override fired. - Staged tree's var/cache/prod is empty — Symfony didn't write into the read-only mount. Together this catches every v0.1.0 shakedown bug in CI: - doubled bin/frankenphp path (resolveFrankenphpBin) - composer path-repo symlink dangling (staging-symfony's symlink:false sed) - read-only mount cache failure (Kernel + supervisor env-vars) - bundle autoload broken (HealthController canary) Implementation gotcha (caught during dev): the host binary must be COPIED into the staged layout, not symlinked. Qt's applicationDirPath() reads /proc/self/exe which dereferences symlinks, so a symlinked host would resolve to the original build/ dir and the supervisor would hunt for frankenphp + symfony there instead of the staged tree. Real AppImages copy the binary, mimicking that here. Wiring: - examples/todo/Makefile: extracted the staging-symfony logic out of the appimage target into its own staging-symfony target. New integration-bundled target depends on `build` + `staging-symfony` and runs tests/bundled-supervisor.sh. quality target now invokes integration-bundled after the existing dev-mode integration test. - .gitea/workflows/ci.yml: new "Bundled-mode supervisor integration test" step right after the dev-mode integration step. Closes the v0.1.1 follow-up "Bundled-mode integration test" tracked in PLAN.md §13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:36:21 +02:00
# ── Verify the cache/log redirect actually fired ───────────────────────
step "verify Symfony wrote cache to user data dir, not the read-only staging"
# Qt's QStandardPaths::AppDataLocation on Linux is $XDG_DATA_HOME/<org>/<app>,
# org="php-qml" comes from main.cpp setOrganizationName, app="todo" from setApplicationName.
USER_DATA="$DATA_DIR/share/php-qml/$APP_NAME"
[ -d "$USER_DATA/var/cache" ] \
|| fail "user-data var/cache missing at $USER_DATA — APP_CACHE_DIR override didn't fire"
# And not into the staged tree (which is chmod -w anyway):
if [ -d "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" ] && \
[ "$(ls -A "$ROOT/usr/share/$APP_NAME/symfony/var/cache/prod" 2>/dev/null)" ]; then
fail "Symfony wrote into the read-only staging tree — Kernel::getCacheDir override broken"
fi
bundled: wipe Symfony cache on every launch — mount path bakes into cache Reproduces with the v0.1.1 AppImage on the second launch (same user data dir, fresh AppImage mount): phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..." Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations" ^^^^^^^ stale path from PREVIOUS launch's cache Symfony compiles `kernel.project_dir` (an absolute path) into its cached container under var/cache/. We redirect var/cache into the user data dir for read-only-mount survival (v0.1.0 fix), but the *content* of that cache references the mount path that was active when the cache was built. Next launch gets a different /tmp/.mount_<random>; the cached refs are stale; first project_dir-sensitive lookup blows up (doctrine migrations was the canary; would also surface as misrouted assets, broken Twig template paths, etc.). Fix: BackendConnection::initBundledMode does QDir(cacheDir).removeRecursively() right after creating the dirs but before runMigrations spawns the doctrine subprocess. Symfony rebuilds the cache against the current mount on every launch. Cost: ~1-2s of warmup per cold start. Permanent fix is build-time cache warmup (ship the prod cache inside the AppImage, copy to user data dir on first launch, no per-launch warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1 takes the simpler always-wipe approach since it's bugfix-class. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "2nd launch from fresh staging" step that tears down the first host, re-stages a fresh fake AppImage layout (different /tmp dir = different "mount path" from BackendConnection's perspective), and asserts /healthz comes back up. Without the cache wipe, that step would fail exactly the way doctrine did in the user's report. Verified locally: - bundled-supervisor.sh passes (incl. 2nd-launch step) - Real AppImage: two consecutive launches both reach "phpqml.bridge.bundled: migrations OK" + frankenphp spawn Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:23:30 +02:00
# ── Second launch: same XDG_DATA_HOME, fresh staging mount ─────────────
# Real AppImages get a fresh /tmp/.mount_<random> per launch but reuse the
# user data dir, so any cached absolute path from launch N is stale by N+1.
# Tear down the running host, re-run from a NEW staging dir (mimicking the
# fresh-mount situation), assert /healthz comes back up.
step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)"
kill -TERM "$PID" 2>/dev/null || true
# 3s grace: teardownChild itself waits up to 2s for frankenphp to finish
# after sending it SIGTERM, so the host can take ~2.x seconds to exit
# cleanly. A 2s loop here was right at the boundary and triggered the
# fallback SIGKILL on slower runners.
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
bundled: wipe Symfony cache on every launch — mount path bakes into cache Reproduces with the v0.1.1 AppImage on the second launch (same user data dir, fresh AppImage mount): phpqml.bridge.bundled: symfony: "/tmp/.mount_Todo-xllnOHH/..." Cannot load migrations from "/tmp/.mount_Todo-xDBkOfG/.../migrations" ^^^^^^^ stale path from PREVIOUS launch's cache Symfony compiles `kernel.project_dir` (an absolute path) into its cached container under var/cache/. We redirect var/cache into the user data dir for read-only-mount survival (v0.1.0 fix), but the *content* of that cache references the mount path that was active when the cache was built. Next launch gets a different /tmp/.mount_<random>; the cached refs are stale; first project_dir-sensitive lookup blows up (doctrine migrations was the canary; would also surface as misrouted assets, broken Twig template paths, etc.). Fix: BackendConnection::initBundledMode does QDir(cacheDir).removeRecursively() right after creating the dirs but before runMigrations spawns the doctrine subprocess. Symfony rebuilds the cache against the current mount on every launch. Cost: ~1-2s of warmup per cold start. Permanent fix is build-time cache warmup (ship the prod cache inside the AppImage, copy to user data dir on first launch, no per-launch warmup) — already tracked as a v0.2.0 item in PLAN.md §13. v0.1.1 takes the simpler always-wipe approach since it's bugfix-class. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "2nd launch from fresh staging" step that tears down the first host, re-stages a fresh fake AppImage layout (different /tmp dir = different "mount path" from BackendConnection's perspective), and asserts /healthz comes back up. Without the cache wipe, that step would fail exactly the way doctrine did in the user's report. Verified locally: - bundled-supervisor.sh passes (incl. 2nd-launch step) - Real AppImage: two consecutive launches both reach "phpqml.bridge.bundled: migrations OK" + frankenphp spawn Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:23:30 +02:00
kill -0 "$PID" 2>/dev/null || break
sleep 0.2
done
kill -KILL "$PID" 2>/dev/null || true
PID=""
chmod -R u+w "$ROOT/usr/share/$APP_NAME/symfony" 2>/dev/null
rm -rf "$ROOT"
ROOT="$(mktemp -d)"
mkdir -p "$ROOT/usr/bin" "$ROOT/usr/share/$APP_NAME"
cp "$HOST_BIN" "$ROOT/usr/bin/$APP_NAME"
ln -s "$(command -v frankenphp)" "$ROOT/usr/bin/frankenphp"
cp -a "$STAGING/." "$ROOT/usr/share/$APP_NAME/symfony/"
cp "$CADDYFILE" "$ROOT/usr/share/$APP_NAME/Caddyfile"
chmod -R a-w "$ROOT/usr/share/$APP_NAME/symfony"
LOG2="$DATA_DIR/host2.log"
env -u BRIDGE_URL \
XDG_DATA_HOME="$DATA_DIR/share" \
XDG_CACHE_HOME="$DATA_DIR/cache" \
XDG_CONFIG_HOME="$DATA_DIR/config" \
QT_QPA_PLATFORM=offscreen \
"$ROOT/usr/bin/$APP_NAME" > "$LOG2" 2>&1 &
PID=$!
DEADLINE=$(( $(date +%s) + 30 ))
HEALTHZ2_BODY=""
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
if ! kill -0 "$PID" 2>/dev/null; then
sed 's/^/ /' "$LOG2" >&2 || true
fail "host died during 2nd boot"
fi
if HEALTHZ2_BODY="$(curl -fsS -m 1 "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"; then
break
fi
sleep 0.2
done
[ -n "$HEALTHZ2_BODY" ] || { sed 's/^/ /' "$LOG2" >&2 || true; fail "/healthz never responded on 2nd launch — stale cache?"; }
echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
|| fail "2nd-launch /healthz didn't return status:ok"
# ── Pre-migration auto-backup ─────────────────────────────────────────
# Launch 1 created data.sqlite; launch 2 should have copied it to
# data.sqlite.<unix-timestamp>.bak before re-running migrations.
step "verify pre-migration backup of data.sqlite was written"
shopt -s nullglob
backups=( "$USER_DATA"/var/data.sqlite.*.bak )
shopt -u nullglob
if [ "${#backups[@]}" -eq 0 ]; then
fail "expected at least one data.sqlite.*.bak under $USER_DATA/var after 2nd launch"
fi
step "found backup: ${backups[0]}"
bundled: SIGTERM the frankenphp child via aboutToQuit, not just the dtor Symptom (user report on v0.1.1): QProcess: Destroyed while process ("/tmp/.mount_Todo-xbNoHHL/usr/bin/frankenphp") is still running. …and the frankenphp child + its PHP workers were left orphaned after the host exited. Cause: teardownChild() was only called from ~BackendConnection. By the time that destructor runs, app.exec() has already returned, QQmlApplicationEngine is mid-destruction, and Qt's event loop is half-torn-down. waitForFinished() doesn't reliably reap the child in that window — QProcess gets destroyed by the QObject parent-chain cleanup before the kernel reports the child as exited. Fix: in BackendConnection's constructor, connect QCoreApplication::aboutToQuit → teardownChild. aboutToQuit fires while the event loop is still active and BEFORE main() starts unwinding the stack, so SIGTERM + waitForFinished can do their job properly. The destructor's teardownChild call stays as belt-and- suspenders (no-op once aboutToQuit has already cleaned up — the function is idempotent via the m_child = nullptr at its end). The connect happens unconditionally in the constructor (not just for bundled mode) because m_child is also nullptr in dev mode and teardownChild handles that with its leading `if (!m_child) return;`. Regression guard: examples/todo/tests/bundled-supervisor.sh gains a "graceful shutdown" step: - Snapshots the host's child PIDs before SIGTERM - SIGTERMs the host, waits up to 3s for clean exit - Greps the host log for "QProcess: Destroyed while" — fail if found - Iterates the snapshotted PIDs, fails on any frankenphp orphan still alive Verified locally: real AppImage + the integration test both clean up without Qt warnings or orphan processes. PLAN.md: new v0.1.2 section above v0.1.1, this is its first entry. CHANGELOG.md: [0.1.2] — TBD section with the same Fixed entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:49:13 +02:00
# ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ──
step "graceful shutdown — assert the supervisor kills its frankenphp child"
SHUTDOWN_PID="$PID"
# Capture every descendant PID before killing, so we can verify they all exit.
DESCENDANTS="$(pgrep -P "$SHUTDOWN_PID" || true)"
kill -TERM "$SHUTDOWN_PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
kill -0 "$SHUTDOWN_PID" 2>/dev/null || break
sleep 0.2
done
if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then
kill -KILL "$SHUTDOWN_PID" 2>/dev/null || true
fail "host didn't exit within 3s of SIGTERM"
fi
PID=""
# Qt warning means QProcess was destroyed before the child exited.
if grep -q "QProcess: Destroyed while process .* is still running" "$LOG2"; then
sed 's/^/ /' "$LOG2" >&2
fail "host exited but logged QProcess-destroyed-while-running warning"
fi
# Any descendant still alive = orphan; the supervisor's teardown didn't wait.
for d in $DESCENDANTS; do
if kill -0 "$d" 2>/dev/null; then
# Be specific: only frankenphp orphans matter (QtNetwork might leave
# short-lived helper threads but those exit on their own).
if ps -p "$d" -o comm= 2>/dev/null | grep -q frankenphp; then
kill -KILL "$d" 2>/dev/null || true
fail "frankenphp child PID $d outlived the host (supervisor didn't clean up)"
fi
fi
done
step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe + clean shutdown)."