v0.2.0 (12/N): bundled-mode port negotiation

PLAN.md §13 v0.2.0 *Bundled-mode port negotiation*. Hardcoded
m_port = 8765 used to fail loudly only when a second php-qml app
launched on the same machine — whichever lost the bind race went
Offline with no recovery path.

Fix:
- Bind a transient QTcpServer to QHostAddress::LocalHost port 0,
  read serverPort(), close. Linux's ephemeral-port allocator
  doesn't immediately reassign the closed port, and FrankenPHP's
  bind happens within milliseconds inside spawnChild() — small
  TOCTOU window in theory, fail-loud in practice if it ever races.
- BRIDGE_PORT env override pins the port for tests / dev
  (bundled-supervisor.sh and perfsmoke.sh now both export it
  instead of the previous PERF_BACKEND_PORT-only knob).
- writePortSentinel() drops the chosen port to
  $XDG_DATA_HOME/<app>/var/bridge.port so external tools can read
  the runtime address without parsing Qt's log output.

Caddyfile already supported {$PORT:8765} env interpolation, so
no template churn. MERCURE_URL is computed from m_url which is
re-derived from the chosen port — no .env changes needed for
bundled mode (dev mode .env still references :8765 since the
developer controls their own frankenphp invocation).

bundled-supervisor.sh integration test gained a sentinel-file
assertion: after first launch, $USER_DATA/var/bridge.port must
exist and contain BRIDGE_PORT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:55:20 +02:00
parent 82de6cae36
commit 6939278857
5 changed files with 76 additions and 0 deletions

View File

@@ -37,6 +37,10 @@ STAGING="$APP_DIR/build/staging-symfony"
CADDYFILE="$APP_DIR/Caddyfile"
APP_NAME=todo
# Force a known port so the test can pre-check / curl without
# parsing the sentinel file. The supervisor honours BRIDGE_PORT
# in bundled mode (PLAN.md §13 v0.2.0 *Port negotiation*); in
# real-world use it negotiates a free ephemeral port instead.
PORT=8765
step() { echo "$*"; }
@@ -52,6 +56,8 @@ if (echo > "/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
skip "port $PORT already in use (dev instance running?)"
fi
export BRIDGE_PORT="$PORT"
# ── Stage a fake AppImage layout in a temp dir ─────────────────────────
ROOT="$(mktemp -d)"
DATA_DIR="$(mktemp -d)"
@@ -195,6 +201,13 @@ echo "$HEALTHZ2_BODY" | grep -q '"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.
# ── Port-negotiation sentinel ─────────────────────────────────────────
step "verify port sentinel was written"
SENTINEL="$USER_DATA/var/bridge.port"
[ -f "$SENTINEL" ] || fail "expected $SENTINEL after first launch"
SENTINEL_PORT="$(cat "$SENTINEL" | tr -d '[:space:]')"
[ "$SENTINEL_PORT" = "$PORT" ] || fail "sentinel port mismatch: got '$SENTINEL_PORT', expected '$PORT'"
step "verify pre-migration backup of data.sqlite was written"
shopt -s nullglob
backups=( "$USER_DATA"/var/data.sqlite.*.bak )