From 69392788570fb841eea938962171022dafada47e Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 20:55:20 +0200 Subject: [PATCH] v0.2.0 (12/N): bundled-mode port negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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) --- CHANGELOG.md | 1 + examples/todo/tests/bundled-supervisor.sh | 13 ++++++ examples/todo/tests/perfsmoke.sh | 4 ++ framework/qml/src/BackendConnection.cpp | 56 +++++++++++++++++++++++ framework/qml/src/BackendConnection.h | 2 + 5 files changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0191bc7..8c75756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0 - **Pre-migration auto-backup of `var/data.sqlite`.** Bundled-mode supervisor copies the SQLite file to `var/data.sqlite..bak` before invoking `doctrine:migrations:migrate`; trims to the 5 most recent. SQLite's lack of transactional DDL means a half-applied migration can corrupt the database with no rollback path; cheap insurance against that. Skipped on first launch (no DB to back up); failure to copy logs a warning and continues (a missing safety-net is not a reason to refuse to boot). Backup runs only in bundled mode — dev mode users own their `var/data.sqlite` lifecycle. Bundled-supervisor integration test gained an assertion that a `.bak` file appears under the user data dir on second launch. - **`bridge:export` console command + QML hook.** New `bin/console bridge:export ` copies the active SQLite database to a user-chosen path (overwrites if the destination exists; reads the source path from `DATABASE_URL` so it works in both dev and bundled mode). Mirrored on the QML side as `BackendConnection.exportDatabase(path)` (`Q_INVOKABLE bool`) returning success synchronously and emitting `databaseExported(path)` / `databaseExportFailed(reason)` for async UX. QML callers typically pair it with `Qt.labs.platform.FileDialog` (see `docs/native-dialogs.md`). 4 unit tests cover the command's success / non-SQLite-URL / missing-source / overwrite paths. - **Periodic auto-update check.** Bundled-mode supervisor arms an `AppImageUpdate` poll on the first `Online` transition: a launch-time check 10 s after backend ready, then a recurring check every 6 hours. PLAN.md §11 *Auto-update* called for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the existing `checkForUpdates()` Q_INVOKABLE remains the install trigger, this just automates the polling. Disable with `BRIDGE_AUTO_UPDATE_DISABLE=1`; override the period with `BRIDGE_AUTO_UPDATE_PERIOD_MIN=`. Dev mode skips entirely. +- **Bundled-mode port negotiation.** The hardcoded `m_port = 8765` is replaced with a runtime-negotiated free ephemeral port: bind a `QTcpServer` to `QHostAddress::LocalHost` port 0, capture `serverPort()`, close, then hand the port to FrankenPHP via the existing `PORT` env var (the Caddyfile already reads `{$PORT:8765}`). Two installed php-qml apps no longer collide on first launch — whichever loses the port-8765 race used to go Offline; now each picks its own. Test harnesses can pin the port via `BRIDGE_PORT=` for reproducibility (the existing `bundled-supervisor.sh` and `perfsmoke.sh` both export it). Each launch also writes the chosen port to `var/bridge.port` so any external tool that needs the runtime address can read it without parsing Qt's log. ### Changed diff --git a/examples/todo/tests/bundled-supervisor.sh b/examples/todo/tests/bundled-supervisor.sh index 72dde97..a7232d4 100755 --- a/examples/todo/tests/bundled-supervisor.sh +++ b/examples/todo/tests/bundled-supervisor.sh @@ -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..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 ) diff --git a/examples/todo/tests/perfsmoke.sh b/examples/todo/tests/perfsmoke.sh index 19ee228..d70ce82 100755 --- a/examples/todo/tests/perfsmoke.sh +++ b/examples/todo/tests/perfsmoke.sh @@ -61,6 +61,10 @@ export XDG_DATA_HOME="$DATA_DIR/share" export XDG_CACHE_HOME="$DATA_DIR/cache" export APPIMAGE_EXTRACT_AND_RUN=1 mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME" +# Force the port so the curl probe below can hit a known address — +# the supervisor would otherwise negotiate a free ephemeral port and +# we'd have to read it back from the sentinel file. +export BRIDGE_PORT="$PERF_BACKEND_PORT" step "launching AppImage (${RUNNER[*]:-direct})" START_NS=$(date +%s%N) diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 092790a..5bcd260 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include #include #include @@ -184,7 +186,24 @@ void BackendConnection::initBundledMode() setToken(randomSecret(32)); m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt + + // Pick a free port instead of hardcoded 8765 — two installed + // php-qml apps used to collide on first launch (whichever lost + // the race went Offline). PLAN.md §13 v0.2.0 *Bundled-mode port + // negotiation*. BRIDGE_PORT env var overrides for tests / dev. + if (qEnvironmentVariableIsSet("BRIDGE_PORT")) { + bool ok = false; + const int forced = qgetenv("BRIDGE_PORT").toInt(&ok); + if (ok && forced > 0 && forced < 65536) { + m_port = static_cast(forced); + qCInfo(lcBundled) << "BRIDGE_PORT override: using port" << m_port; + } + } else { + m_port = pickFreePort(); + qCInfo(lcBundled) << "negotiated port:" << m_port; + } setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port)); + writePortSentinel(); if (!runMigrations()) { return; // setError already invoked @@ -301,6 +320,43 @@ void BackendConnection::backupDatabase() } } +quint16 BackendConnection::pickFreePort() const +{ + // Bind to port 0 → kernel allocates an ephemeral port → close → + // the port stays available for frankenphp's bind a few ms later. + // There is a TOCTOU window (another process could grab the port + // in between), but on Linux the kernel does not eagerly reassign + // recently-released ephemeral ports and frankenphp's bind happens + // synchronously inside spawnChild. If we lose the race, the user + // sees a "spawning frankenphp on port N" log followed by a bind + // failure — fail loud rather than silently retrying with a + // potentially-also-collided port. + QTcpServer probe; + if (!probe.listen(QHostAddress::LocalHost, 0)) { + qCWarning(lcBundled) << "could not bind a free port; falling back to default 8765"; + return 8765; + } + const quint16 port = probe.serverPort(); + probe.close(); + return port; +} + +void BackendConnection::writePortSentinel() const +{ + // Write the chosen port to var/bridge.port so test harnesses + // (bundled-supervisor.sh, perfsmoke.sh) can discover it without + // needing to parse Qt's log output. Same data dir the supervisor + // uses for cache + sqlite, so XDG_DATA_HOME isolation in tests + // keeps each run's sentinel separate. + const QString path = m_dataDir + QStringLiteral("/var/bridge.port"); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + qCWarning(lcBundled) << "could not write port sentinel" << path << ":" << f.errorString(); + return; + } + QTextStream(&f) << m_port << '\n'; +} + bool BackendConnection::runMigrations() { backupDatabase(); diff --git a/framework/qml/src/BackendConnection.h b/framework/qml/src/BackendConnection.h index 6ddbab5..7e56ae4 100644 --- a/framework/qml/src/BackendConnection.h +++ b/framework/qml/src/BackendConnection.h @@ -131,6 +131,8 @@ private: void initBundledMode(); bool runMigrations(); void backupDatabase(); + quint16 pickFreePort() const; + void writePortSentinel() const; bool spawnChild(QString* errorOut = nullptr); void teardownChild(); QString resolveFrankenphpBin() const;