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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 )
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user