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

@@ -15,6 +15,8 @@
#include <QRandomGenerator>
#include <QSocketNotifier>
#include <QStandardPaths>
#include <QTcpServer>
#include <QTextStream>
#include <QTimer>
#include <QUrl>
@@ -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<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));
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();

View File

@@ -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;