The aboutToQuit-based teardown wired in v0.1.2 only fires when something calls QCoreApplication::quit() — typically a window close. `kill -TERM` to the host process bypasses Qt entirely (no default SIGTERM handler), so teardownChild never ran on signal-driven shutdown. Local tests passed on lucky timing because PR_SET_PDEATHSIG made the kernel SIGTERM frankenphp once the host died, but the timing was racy and surfaced on CI as "frankenphp child PID outlived the host (supervisor didn't clean up)". Fix: install a SIGTERM/SIGINT handler in BackendConnection that uses the self-pipe pattern — the C signal handler writes one byte (the only truly async-signal-safe primitive), a QSocketNotifier on the read end calls QCoreApplication::quit() in the main thread, and aboutToQuit runs the existing teardownChild before app.exec() returns. The host now exits cleanly under `kill -TERM` from service managers, launchers, and the test harness. Also bumps the bundled-supervisor.sh first-relaunch grace from 2s to 3s — teardownChild itself waits up to 2s for frankenphp to finish after SIGTERM, so the host needs ~2.x seconds to exit. The graceful-shutdown step further down was already at 3s. No public-API change; production-correctness fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
examples/todo
The framework's POC application: a real Qt/QML todo app whose backend is a Symfony service generated entirely by the make:bridge:* makers. Demonstrates every architectural primitive in PLAN.md §13 Phase 3.
What's here that wasn't in the skeleton
Todoresource (entity + controller + QML snippet) generated viamake:bridge:resource Todo.MarkAllDonecommand generated viamake:bridge:command MarkAllDone, with a body that flipsdone = trueon every todo.TodoWindow.qmlsecond-window scaffold generated viamake:bridge:window Todo, customised to embed a read-only mirror of the sameReactiveListModel.Main.qmlrewritten as a real list UI with add input, per-row toggle/delete, "mark all done", and "open second window".
Everything cross-side (PHP ↔ QML) is maker-generated. There is no handwritten bridge glue in this example.
Run it
make install # composer install
make build # cmake + qt host
make doctor # bridge:doctor — readiness check
make dev # FrankenPHP --watch + Qt host
Add a few todos, toggle them, click "Mark all done", click "Open second window" — both windows stay in sync.
Multi-window test
make devstarts the app.- Add three todos via the input field.
- Click Open second window. A
Todos (mirror)window opens. - In the main window, toggle one of the todos. The mirror flips its row within ~50 ms (Mercure SSE).
- Add a new todo in the main window. It appears in the mirror within ~50 ms.
- Click Mark all done in the main window. Both windows show all rows ticked simultaneously.
Each window has its own ReactiveListModel instance subscribed to the same app://model/todo topic; the framework keeps them coherent without per-window glue.
Crash-and-recover test
make devis running.- Add a todo so the list is non-empty.
- From another terminal:
pkill -f 'examples/todo/symfony.*frankenphp'. - The app's
AppShellshows the Reconnecting banner (visible on the second window; the main window keeps its own status display). - Restart the FrankenPHP child: from the example dir,
cd symfony && frankenphp run --watch --config ../Caddyfile— or just re-runmake dev. - The app flips back to Online and re-fetches the list. No row corruption.
Layout
todo/
Caddyfile # FrankenPHP / Mercure config (port 8765, anonymous SSE)
Makefile # build / dev / doctor / quality
scripts/dev.sh
symfony/
composer.json # path repo points one level deeper at framework/php
config/
src/Entity/Todo.php # generated by make:bridge:resource
src/Controller/TodoController.php # generated, CRUD on /api/todos
src/Controller/MarkAllDoneController.php # generated, body filled in
public/index.php
bin/console
.env
migrations/
qml/
CMakeLists.txt
main.cpp
Main.qml # real todo UI (add / toggle / delete / mark-all / 2nd-window)
TodoList.qml # generated; also reused by the second window
TodoWindow.qml # generated, customised to embed mirror list
What this example proves
- The headline workflow scales: a non-trivial app's PHP/QML wiring is all generated.
ReactiveListModelhandles in-flight optimistic mutations (pendingrole drops opacity on toggled rows until the Mercure echo lands).- Two windows of the same QML app stay coherent under mutations from either side.
- Stopping FrankenPHP mid-session triggers
Reconnecting→OfflineUI; restart restoresOnlineand re-fetches without dupes. Idempotency-Keyround-trips through to Mercure ascorrelationKey— visible indev.logif yougrep correlationKey.
Out of scope here
- A CI-driven version of the multi-window / crash-recover tests lives in Phase 3 sub-commit 4 as a bridge-integration suite.
- No persistence options (export, backup) — same SQLite
var/data.sqliteas the skeleton. Apps move to Postgres by overridingDATABASE_URL.
AppImage packaging (Phase 4a)
FRANKENPHP=/path/to/frankenphp make appimage
./build/Todo-x86_64.AppImage
make appimage stages a composer install --no-dev copy of the Symfony app, downloads linuxdeploy + appimagetool + the AppImageUpdate sidecar (cached under packaging/linux/tools/, gitignored), and produces build/Todo-x86_64.AppImage (~150 MB). The AppImage carries everything needed: Qt runtime, the bundled FrankenPHP binary, the Symfony app, and an AppImageUpdate.AppImage sidecar for in-place updates.
When the AppImage runs without BRIDGE_URL set, BackendConnection switches to bundled mode: it spawns the embedded FrankenPHP, generates a per-session bearer token, runs first-launch migrations into ~/.local/share/php-qml/todo/var/data.sqlite, and supervises the child. Killing FrankenPHP from outside the AppImage triggers an automatic restart with a fresh token (PLAN.md §3 Edge cases — Per-session secret rotation).
Auto-update
The AppImage is built with an embedded update-info ELF section pointing at the canonical Gitea Releases URL for its tag (set via APPIMAGE_UPDATE_INFO at build time). The bundled sidecar implements the actual download and patch via zsync.
From QML:
Connections {
target: BackendConnection
function onUpdatesAvailable() { ... }
function onNoUpdatesAvailable() { ... }
function onUpdateApplied() { ... } // restart prompt
}
Button { text: "Check for updates"; onClicked: BackendConnection.checkForUpdates() }
Button { text: "Update"; onClicked: BackendConnection.applyUpdate() }
Performance smoke
make perf
Asserts the §11 Performance budgets: bundle ≤ 200 MB, cold start ≤ 2 s (4 s on shared CI runners), idle RSS ≤ 200 MB. CI runs this on every release tag before publishing.