The README still framed the project as "Phase 5 / pre-v0.1.0" and the docs predated the v0.2.0 surface (typed BridgeOp, public service interfaces, port negotiation, pre-migration auto-backup, bridge:export, periodic auto-update, two new makers, qmltestrunner). Bring them in line with what's actually shipped, and add badges (release, license, PHP, Symfony, Qt, FrankenPHP, CI, platform) to the README so the status is legible at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
Bundled mode
What changes when an app runs without BRIDGE_URL — typically launched from a packaged AppImage. Dev-mode behaviour is described in Dev workflow; this page covers what the user-visible binary actually does.
Detection
BackendConnection's constructor checks qgetenv("BRIDGE_URL"):
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
if (!explicitUrl.isEmpty()) {
m_mode = Mode::Dev;
// …
} else {
m_mode = Mode::Bundled;
QTimer::singleShot(0, this, &BackendConnection::initBundledMode);
}
Bundled-mode init runs after the QApplication event loop is up so QStandardPaths and QProcess work correctly.
Port negotiation
Bundled mode does not hardcode a TCP port. On every spawn the host:
- Binds a
QTcpServertoQHostAddress::LocalHostport 0 (kernel picks a free ephemeral port). - Captures
serverPort(), then closes the probe socket. - Hands the chosen port to FrankenPHP via the
PORTenv var.
The bundled Caddyfile reads {$PORT:8765}, so it picks up whatever the host negotiated and falls back to 8765 only when the env is unset (i.e. dev mode without an override).
The chosen port is also written to ~/.local/share/<app>/var/bridge.port on every launch. External tools (a debug helper, a curl /healthz from a script) can read the file instead of grepping Qt's log for the address.
For reproducible test harnesses, pin the port via the BRIDGE_PORT=<n> env var. Both examples/todo/tests/bundled-supervisor.sh and tests/perfsmoke.sh set this so multiple harnesses can run side by side without contending. When BRIDGE_PORT is set the negotiation step is skipped.
Why negotiate at all? Two installed php-qml apps used to race for 8765 on first launch — whichever lost went Offline. Negotiation eliminates the collision class; the kernel guarantees uniqueness within the host.
Resolving the FrankenPHP child
Bundled mode needs three things on disk near the host binary:
| What | Default location | Override |
|---|---|---|
| FrankenPHP binary | <bin>/bin/frankenphp |
BRIDGE_FRANKENPHP_BIN |
| Symfony app | <bin>/symfony (or ../symfony, ../share/<app>/symfony, ../usr/share/<app>/symfony) |
BRIDGE_SYMFONY_DIR |
| Caddyfile | <bin>/Caddyfile (or the same share/<app>/ candidates) |
BRIDGE_CADDYFILE |
The candidate-list approach exists because AppImages, .deb packages, and make install layouts all put files in subtly different places. The first match wins.
If any of the three is missing, BackendConnection flips to Offline with an explanatory error before ever spawning anything. BackendConnection.error is rendered in AppShell's offline overlay.
Per-session secrets
Every spawn (initial + every supervisor restart) generates fresh secrets:
setToken(randomSecret(32)); // 32 bytes → base64url, 43-char token
m_jwtSecret = randomSecret(48); // 48 bytes → ≥256 bits for lcobucci/jwt
Three values are derived:
BRIDGE_TOKEN— bearer token. The QML side reads it viaBackendConnection.tokenandRestClientputs it on everyAuthorization: Bearer …header.MERCURE_JWT_SECRET/MERCURE_PUBLISHER_JWT_KEY/MERCURE_SUBSCRIBER_JWT_KEY— same value, used to mint Mercure JWTs.APP_SECRET— Symfony's framework secret. Just kept fresh for hygiene.
Why per-session? An attacker on the same machine who learns the token between sessions can't use it again — restarting the AppImage rotates everything. The secrets never touch disk: they live in env vars passed to QProcess::start and are gone the moment the host exits.
Token rotation across supervisor restarts
When the supervisor restarts FrankenPHP after a crash:
void BackendConnection::onChildFinished(...) {
// …
setToken(randomSecret(32));
emit tokenRotated(m_token);
spawnChild(&err);
setState(ConnectionState::Reconnecting);
}
tokenRotated is a signal that RestClient and MercureClient listen to — they swap the new bearer in for their next request. In-flight requests fail (the old token is rejected), the optimistic mutations they were carrying roll back, and the user sees a brief Reconnecting banner before things flip back to Online.
First-launch migrations
Before spawning the long-lived child, bundled mode runs migrations against the user-local SQLite file:
QProcess proc;
proc.setProgram(resolveFrankenphpBin());
proc.setArguments({
"php-cli",
resolveSymfonyDir() + "/bin/console",
"doctrine:migrations:migrate",
"-n",
});
env.insert("DATABASE_URL", databaseUrl()); // sqlite:///<userdata>/var/data.sqlite
frankenphp php-cli runs the embedded PHP CLI without booting the Caddy server, so migrations don't conflict with the running child later. The DB file lives at ~/.local/share/<app>/var/data.sqlite (Linux) — QStandardPaths::AppDataLocation with a fallback.
If migrations fail or time out, bundled mode goes Offline before spawning. Apps that want to handle a corrupt DB gracefully can detect this via BackendConnection.error and show a "reset database?" UI.
Pre-migration auto-backup
Before invoking doctrine:migrations:migrate, the supervisor copies var/data.sqlite to var/data.sqlite.<unix-timestamp>.bak and keeps the 5 most recent backups. SQLite has no transactional DDL — a half-applied migration can corrupt the database with no rollback path. The backup is cheap insurance against that.
- Skipped on first launch (no DB exists yet).
- Failure to copy logs a warning and continues — a missing safety net is not a reason to refuse to boot.
- Bundled mode only. Dev-mode users own
symfony/var/data.sqlitethemselves.
If migration corrupts the DB, the user's recovery is cp var/data.sqlite.<latest>.bak var/data.sqlite from the data directory; the next launch boots against the rolled-back schema (and a future migration retry succeeds against the original state).
Database export
Apps can offer a "Save a copy of my data" button by calling BackendConnection.exportDatabase(path) (Q_INVOKABLE) — typically paired with Qt.labs.platform.FileDialog. The same operation is available as bin/console bridge:export <destination> for CLI use. Both read the source path from DATABASE_URL so they work in dev mode and bundled mode unchanged.
Supervisor
The supervisor is BackendConnection::onChildFinished() plus a retry counter:
static constexpr int kMaxSupervisorRetries = 5;
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus) {
if (m_supervisorRetries >= kMaxSupervisorRetries) {
setState(ConnectionState::Offline);
return;
}
++m_supervisorRetries;
setToken(randomSecret(32));
emit tokenRotated(m_token);
spawnChild(&err);
setState(ConnectionState::Reconnecting);
}
Five rapid restarts in a row → permanently Offline. The retry counter resets to 0 every time a probe succeeds (onProbeFinished), so a child that crashes once a week never accumulates.
The user can always force a fresh round via BackendConnection.restart() (the AppShell offline overlay's Retry button does this).
Why a counter and not exponential backoff
The failure mode we care about is port already taken or migration broke the DB. Both are deterministic — a fast retry just hits the same wall. Five attempts is enough to ride out a transient issue (e.g. another instance shutting down) without spinning forever.
prctl PR_SET_PDEATHSIG
Linux-specific safety: when the host dies for any reason — segfault, OOM, SIGKILL — the kernel guarantees the child dies too:
m_child->setChildProcessModifier([] {
prctl(PR_SET_PDEATHSIG, SIGTERM);
});
Without this, a host crash leaves an orphan FrankenPHP process holding the negotiated port (and consuming the user's data files); the next launch finds no parent to connect back to but the orphan still races for resources.
PR_SET_PDEATHSIG only works on Linux. macOS and Windows builds will use platform-equivalents in their respective phases (see PLAN.md §4b/§4c).
Healthz probe
Same as dev mode: GET /healthz every 5 s, 2 s timeout, 30 s threshold for Offline. Bundled mode adds a 10 s "boot grace" window so the first probe failures during FrankenPHP startup don't immediately count toward Offline.
Auto-update
Bundled mode wires up the AppImageUpdate sidecar — see Linux packaging §auto-update. Three QML signals carry the result:
Connections {
target: BackendConnection
function onUpdatesAvailable() { /* show "Update available" UI */ }
function onNoUpdatesAvailable() { /* "you're up to date" */ }
function onUpdateApplied() { /* prompt user to restart */ }
}
Button { text: "Check"; onClicked: BackendConnection.checkForUpdates() }
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Both methods are no-ops in dev mode — they emit updateCheckFailed("update checks are bundled-mode only") so QML can treat them uniformly.
Periodic check
The supervisor arms an automatic poll on the first Online transition: a launch-time check 10 s after the backend is ready, then a recurring check every 6 hours. PLAN.md §11 Auto-update asked for "check on launch and once per N hours; offer install on next restart, never auto-restart" — the poll surfaces updatesAvailable() so apps can show a banner; applyUpdate() is still the explicit install trigger and there is no auto-restart.
| Env var | Default | Effect |
|---|---|---|
BRIDGE_AUTO_UPDATE_DISABLE |
unset | Set to 1 to disable the periodic poll. The Q_INVOKABLE checkForUpdates() / applyUpdate() still work. |
BRIDGE_AUTO_UPDATE_PERIOD_MIN |
360 (6 h) |
Override the period in minutes. |
Dev mode skips the periodic check entirely.
Single-instance lock
The host uses SingleInstance (a QLocalServer-backed lock) regardless of mode:
PhpQml::Bridge::SingleInstance singleInstance("my-app");
if (!singleInstance.acquireOrForward(app.arguments())) {
return 0; // forwarded; existing instance handles it
}
The first instance binds a unix socket at ~/.local/share/<app>/<app>.sock. Subsequent launches connect, dump their argv over the socket, and exit. The running instance receives them via SingleInstance::launchArgsReceived(args) and can show its window or open the file passed.
Bundled mode and dev mode use the same socket name, so launching the AppImage while make dev is running lights up the dev-mode window — handy for "click an .appimage while I'm hacking" tests.
What bundled mode doesn't do
- No file watcher. Production AppImages run with cached opcache; saving files inside the SquashFS would do nothing anyway.
- No
make:*makers.composer install --no-devstrips them. Apps that want runtime code generation are doing something the framework isn't designed for. - No persistent log file. The child's stdout+stderr are captured into
BackendConnection's 500-line ring buffer (the source ofDevConsole). For persistent logs, configure Symfony's monolog as usual; the bundled FrankenPHP writes to~/.local/share/<app>/var/log/.
Where to look in the code
framework/qml/src/BackendConnection.cpp:initBundledMode()— env detection, secret generation, migrations, spawn.runMigrations()— thefrankenphp php-cliinvocation.spawnChild()— env composition +MergedChannels+prctl.onChildFinished()/onChildOutputReady()— supervisor + log capture.resolveFrankenphpBin()/resolveSymfonyDir()/resolveCaddyfilePath()— candidate lists.
See also
- Architecture — the broader process-pair picture.
- Linux packaging — how an AppImage gets the FrankenPHP child + Symfony app + Caddyfile next to its binary.
- Update semantics — what happens to in-flight mutations when bundled-mode restarts FrankenPHP.