Files
php-qml/docs/bundled-mode.md
magdev da048434b8 docs: rewrite README + add comprehensive docs/
README is now tight and link-heavy: 60-second tour, then deep links
into docs/. The wall of detail moved out.

docs/ covers the framework end-to-end:
- getting-started.md — prerequisites by distro (Tumbleweed, Fedora,
  Debian/Ubuntu, Arch), full first-run walkthrough, troubleshooting.
- architecture.md — process pair, transport, dev/bundled mode.
- update-semantics.md — state machine + optimistic mutations + key
  round-tripping.
- reactive-models.md — ReactiveListModel, ReactiveObject, Mercure
  dual-publish.
- makers.md — make:bridge:resource/command/window.
- dev-workflow.md — hot reload (PHP + QML), dev console, editor
  configs, bridge:doctor, snapshot/integration test loops, perfsmoke.
- bundled-mode.md — supervisor, per-session secret rotation,
  first-launch migrations, auto-update wiring.
- packaging-linux.md — make appimage, build-appimage.sh CLI,
  AppImageUpdate sidecar, perfsmoke budgets, release CI, bundle-size
  breakdown.
- qml-api.md / php-api.md — exhaustive symbol reference with all
  Q_PROPERTY/Q_INVOKABLE/signals + every public PHP service / attribute
  / command.
- configuration.md — every env var (host, Symfony, dev script,
  packaging script, perfsmoke), every CLI flag (php-qml-init,
  build-appimage.sh), make targets, default ports/paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:18:37 +02:00

8.6 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.

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.

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 port 8765, and the next launch can't bind.

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 also 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.

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.