The aboutToQuit-based teardown wired in v0.1.2 only fires when something calls QCoreApplication::quit() — typically a window close. `kill -TERM` to the host process bypasses Qt entirely (no default SIGTERM handler), so teardownChild never ran on signal-driven shutdown. Local tests passed on lucky timing because PR_SET_PDEATHSIG made the kernel SIGTERM frankenphp once the host died, but the timing was racy and surfaced on CI as "frankenphp child PID outlived the host (supervisor didn't clean up)". Fix: install a SIGTERM/SIGINT handler in BackendConnection that uses the self-pipe pattern — the C signal handler writes one byte (the only truly async-signal-safe primitive), a QSocketNotifier on the read end calls QCoreApplication::quit() in the main thread, and aboutToQuit runs the existing teardownChild before app.exec() returns. The host now exits cleanly under `kill -TERM` from service managers, launchers, and the test harness. Also bumps the bundled-supervisor.sh first-relaunch grace from 2s to 3s — teardownChild itself waits up to 2s for frankenphp to finish after SIGTERM, so the host needs ~2.x seconds to exit. The graceful-shutdown step further down was already at 3s. No public-API change; production-correctness fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
9.9 KiB
Bash
Executable File
228 lines
9.9 KiB
Bash
Executable File
#!/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\\\\Publisher"' \
|
|
|| fail "/healthz missing bundle field — HealthController deep-load broken"
|
|
|
|
# ── 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
|
|
|
|
# ── 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
|
|
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"
|
|
|
|
# ── 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)."
|