Files
php-qml/docs/bundled-mode.md
magdev beb4e3ab9d docs: refresh README + docs/ for v0.2.0
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>
2026-05-03 22:27:52 +02:00

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:

  1. Binds a QTcpServer to QHostAddress::LocalHost port 0 (kernel picks a free ephemeral port).
  2. Captures serverPort(), then closes the probe socket.
  3. Hands the chosen port to FrankenPHP via the PORT env 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 via BackendConnection.token and RestClient puts it on every Authorization: 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.sqlite themselves.

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-dev strips 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 of DevConsole). 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() — the frankenphp php-cli invocation.
    • 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.