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

@@ -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.<unix-timestamp>.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. - **Pre-migration auto-backup of `var/data.sqlite`.** Bundled-mode supervisor copies the SQLite file to `var/data.sqlite.<unix-timestamp>.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 <destination>` 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. - **`bridge:export` console command + QML hook.** New `bin/console bridge:export <destination>` 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=<minutes>`. Dev mode skips entirely. - **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=<minutes>`. 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=<n>` 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 ### Changed

View File

@@ -37,6 +37,10 @@ STAGING="$APP_DIR/build/staging-symfony"
CADDYFILE="$APP_DIR/Caddyfile" CADDYFILE="$APP_DIR/Caddyfile"
APP_NAME=todo 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 PORT=8765
step() { echo "$*"; } 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?)" skip "port $PORT already in use (dev instance running?)"
fi fi
export BRIDGE_PORT="$PORT"
# ── Stage a fake AppImage layout in a temp dir ───────────────────────── # ── Stage a fake AppImage layout in a temp dir ─────────────────────────
ROOT="$(mktemp -d)" ROOT="$(mktemp -d)"
DATA_DIR="$(mktemp -d)" DATA_DIR="$(mktemp -d)"
@@ -195,6 +201,13 @@ echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
# ── Pre-migration auto-backup ───────────────────────────────────────── # ── Pre-migration auto-backup ─────────────────────────────────────────
# Launch 1 created data.sqlite; launch 2 should have copied it to # Launch 1 created data.sqlite; launch 2 should have copied it to
# data.sqlite.<unix-timestamp>.bak before re-running migrations. # 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" step "verify pre-migration backup of data.sqlite was written"
shopt -s nullglob shopt -s nullglob
backups=( "$USER_DATA"/var/data.sqlite.*.bak ) backups=( "$USER_DATA"/var/data.sqlite.*.bak )

View File

@@ -61,6 +61,10 @@ export XDG_DATA_HOME="$DATA_DIR/share"
export XDG_CACHE_HOME="$DATA_DIR/cache" export XDG_CACHE_HOME="$DATA_DIR/cache"
export APPIMAGE_EXTRACT_AND_RUN=1 export APPIMAGE_EXTRACT_AND_RUN=1
mkdir -p "$XDG_DATA_HOME" "$XDG_CACHE_HOME" 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})" step "launching AppImage (${RUNNER[*]:-direct})"
START_NS=$(date +%s%N) START_NS=$(date +%s%N)

View File

@@ -15,6 +15,8 @@
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QSocketNotifier> #include <QSocketNotifier>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTcpServer>
#include <QTextStream>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
@@ -184,7 +186,24 @@ void BackendConnection::initBundledMode()
setToken(randomSecret(32)); setToken(randomSecret(32));
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt 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<quint16>(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)); setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
writePortSentinel();
if (!runMigrations()) { if (!runMigrations()) {
return; // setError already invoked 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() bool BackendConnection::runMigrations()
{ {
backupDatabase(); backupDatabase();

View File

@@ -131,6 +131,8 @@ private:
void initBundledMode(); void initBundledMode();
bool runMigrations(); bool runMigrations();
void backupDatabase(); void backupDatabase();
quint16 pickFreePort() const;
void writePortSentinel() const;
bool spawnChild(QString* errorOut = nullptr); bool spawnChild(QString* errorOut = nullptr);
void teardownChild(); void teardownChild();
QString resolveFrankenphpBin() const; QString resolveFrankenphpBin() const;