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>
229 lines
12 KiB
Markdown
229 lines
12 KiB
Markdown
# 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](dev-workflow.md); this page covers what the user-visible binary actually does.
|
|
|
|
## Detection
|
|
|
|
`BackendConnection`'s constructor checks `qgetenv("BRIDGE_URL")`:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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)`](qml-api.md#exportdatabase) (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:
|
|
|
|
```cpp
|
|
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:
|
|
|
|
```cpp
|
|
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](packaging-linux.md#auto-update). Three QML signals carry the result:
|
|
|
|
```qml
|
|
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:
|
|
|
|
```cpp
|
|
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](architecture.md) — the broader process-pair picture.
|
|
- [Linux packaging](packaging-linux.md) — how an AppImage gets the FrankenPHP child + Symfony app + Caddyfile next to its binary.
|
|
- [Update semantics](update-semantics.md#edge-cases) — what happens to in-flight mutations when bundled-mode restarts FrankenPHP.
|