Add Phase 0 spike: end-to-end transport verified
Bare PHP behind FrankenPHP plus a Qt/QML host that spawns it. GET /api/ping publishes a Mercure event; the QML window receives it back over the SSE stream. Findings (Caddy directive ordering, Mercure transport scalar, PR_SET_PDEATHSIG for child cleanup, PHP 8.5 curl_close deprecation, port collision with system FrankenPHP, pure-QML SSE viability) are recorded in spike/README.md so Phase 1 starts from a known-good baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
spike/.gitignore
vendored
Normal file
2
spike/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
bin/
|
||||||
|
build/
|
||||||
28
spike/Caddyfile
Normal file
28
spike/Caddyfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
auto_https off
|
||||||
|
admin off
|
||||||
|
frankenphp
|
||||||
|
order php_server before respond
|
||||||
|
order mercure after encode
|
||||||
|
}
|
||||||
|
|
||||||
|
http://127.0.0.1:8765 {
|
||||||
|
root * php/
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
mercure {
|
||||||
|
transport local
|
||||||
|
publisher_jwt !ChangeThisDevKey!
|
||||||
|
subscriber_jwt !ChangeThisDevKey!
|
||||||
|
anonymous
|
||||||
|
publish_origins *
|
||||||
|
cors_origins *
|
||||||
|
}
|
||||||
|
|
||||||
|
php_server
|
||||||
|
|
||||||
|
log {
|
||||||
|
output stderr
|
||||||
|
format console
|
||||||
|
}
|
||||||
|
}
|
||||||
88
spike/README.md
Normal file
88
spike/README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Phase 0 — Throwaway transport spike
|
||||||
|
|
||||||
|
The smallest thing that proves the php-qml architecture's transport works end-to-end on Linux.
|
||||||
|
|
||||||
|
A Qt/QML host spawns a bundled FrankenPHP process, hits `GET /api/ping` over local HTTP, and subscribes to Mercure SSE. Clicking "Ping" makes a request whose handler also publishes a Mercure event — that event then arrives back in the QML window via the SSE stream.
|
||||||
|
|
||||||
|
Lifetime of this directory is short: it gets removed when Phase 1's framework skeleton supersedes it. See [PLAN.md §13](../PLAN.md#13-roadmap-to-poc).
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
Prerequisites on the dev machine:
|
||||||
|
|
||||||
|
- Linux (tested on openSUSE Tumbleweed)
|
||||||
|
- `cmake`, `qt6-base-devel`, `qt6-declarative-devel`, `qt6-quickcontrols2-devel`, `qt6-tools-devel`, `gcc-c++`
|
||||||
|
- `curl` (for the FrankenPHP download)
|
||||||
|
|
||||||
|
Fetch FrankenPHP if `bin/frankenphp` is missing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL -o spike/bin/frankenphp \
|
||||||
|
https://github.com/php/frankenphp/releases/download/v1.12.2/frankenphp-linux-x86_64
|
||||||
|
chmod +x spike/bin/frankenphp
|
||||||
|
```
|
||||||
|
|
||||||
|
Then from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./spike/run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds the Qt binary on first run (into `spike/build/`) and launches it. The Qt host spawns FrankenPHP as a child; closing the window cleans both up.
|
||||||
|
|
||||||
|
## What you should see
|
||||||
|
|
||||||
|
A window with two status dots — backend and Mercure — that flip green within a second of launch. Clicking **Ping** appends two lines to the event log: an outgoing `→ ping` line with the response payload and timing, and an incoming `← event …` line carrying the Mercure-pushed copy of the same payload. The two arrive within ~50 ms of each other on a quiet machine.
|
||||||
|
|
||||||
|
Killing the child manually from another terminal — `pkill -f spike/bin/frankenphp` — flips the status dots and the QML logs `· mercure → disconnected`. Re-running `run.sh` reconnects.
|
||||||
|
|
||||||
|
## What's hardcoded
|
||||||
|
|
||||||
|
| Thing | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Backend URL | `http://127.0.0.1:8765` |
|
||||||
|
| Mercure topic | `app://ping` |
|
||||||
|
| Mercure JWT (publisher + subscriber) | `!ChangeThisDevKey!` (anonymous subscribers are also enabled) |
|
||||||
|
| FrankenPHP version | `v1.12.2` (pinned in this README; `bin/frankenphp` is gitignored) |
|
||||||
|
| Port | `8765` (chosen to avoid the system FrankenPHP that already binds 8080 on this box) |
|
||||||
|
|
||||||
|
No auth on `/api/ping`. No `Last-Event-ID` resume. No optimistic updates. All of those land in Phase 1+.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
spike/
|
||||||
|
Caddyfile # FrankenPHP / Caddy / Mercure config
|
||||||
|
run.sh # build + run
|
||||||
|
bin/frankenphp # downloaded static binary (gitignored)
|
||||||
|
php/
|
||||||
|
index.php # GET /api/ping → JSON pong + Mercure publish
|
||||||
|
qt/
|
||||||
|
CMakeLists.txt
|
||||||
|
main.cpp # Qt host: spawns frankenphp, installs SIGTERM handler, prctl(PR_SET_PDEATHSIG)
|
||||||
|
Main.qml # window UI: status dots, Ping button, event log
|
||||||
|
Mercure.qml # pure-QML SSE client over XMLHttpRequest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Findings worth carrying into Phase 1
|
||||||
|
|
||||||
|
- **The `mercure` Caddy directive is site-level, not global.** It goes inside the site block alongside `php_server`, not in the `{}` global block.
|
||||||
|
- **The `transport` directive is now scalar, not URL.** `transport_url local://local` is deprecated in FrankenPHP 1.12; use `transport local`.
|
||||||
|
- **A default Mercure config requires a transport.** Without one, FrankenPHP falls back to `bolt` and demands a database file, which fails with `invalid transport: timeout` on first boot. For dev / spikes, `transport local` (in-memory) is the right choice.
|
||||||
|
- **Subprocess cleanup needs both belt and suspenders.** Qt's `aboutToQuit` only fires for graceful exits; for `SIGTERM` we need an explicit signal handler that calls `QCoreApplication::quit()`. As a Linux safety net, `prctl(PR_SET_PDEATHSIG, SIGTERM)` in the child via `QProcess::setChildProcessModifier()` ensures the kernel reaps the child even if the parent crashes.
|
||||||
|
- **PHP 8.5 deprecates `curl_close()`.** It's been a no-op since 8.0; spike code now relies on the variable going out of scope. Worth scrubbing for in Phase 1's PHP code style rules.
|
||||||
|
- **The system can have a pre-existing FrankenPHP on `:8080`.** Hardcoded port choice for the spike is `:8765`; for the framework proper, pick the port at runtime per session (§3, *Startup*) or use a unix socket on Linux/macOS.
|
||||||
|
- **Pure-QML SSE works.** `XMLHttpRequest` in QML 6.11 does deliver partial responses during `readyState === LOADING`, so `Mercure.qml` parses the stream without a C++ helper. The C++ `MercureClient` planned in PLAN.md §7 is still the right call for Phase 1 (reconnect, `Last-Event-ID`, backpressure), but it's not blocked on a Qt limitation.
|
||||||
|
- **JWT minting in plain PHP is ten lines.** Symfony's `mercure-bundle` is convenience, not necessity — useful to know for the Phase 1 SessionAuthenticator.
|
||||||
|
|
||||||
|
## Out of scope for this spike
|
||||||
|
|
||||||
|
(All deferred to Phase 1+, see [PLAN.md §13](../PLAN.md#13-roadmap-to-poc).)
|
||||||
|
|
||||||
|
- Symfony, Doctrine, any framework
|
||||||
|
- Per-session secret, real auth
|
||||||
|
- `Last-Event-ID` resume / reconnect with backoff
|
||||||
|
- Optimistic updates, `pending` role, `connectionState` enum
|
||||||
|
- Single-instance lock, deep links
|
||||||
|
- Packaging, auto-update, code signing
|
||||||
|
- Tests (manual run only)
|
||||||
64
spike/php/index.php
Normal file
64
spike/php/index.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
const MERCURE_KEY = '!ChangeThisDevKey!';
|
||||||
|
const MERCURE_HUB = 'http://127.0.0.1:8765/.well-known/mercure';
|
||||||
|
const MERCURE_TOPIC = 'app://ping';
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||||
|
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
|
||||||
|
if ($path === '/api/ping' && $method === 'GET') {
|
||||||
|
$payload = ['pong' => true, 'now' => date('c')];
|
||||||
|
publishToMercure(MERCURE_TOPIC, $payload);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'not found', 'path' => $path]);
|
||||||
|
|
||||||
|
function publishToMercure(string $topic, array $data): void
|
||||||
|
{
|
||||||
|
$body = http_build_query([
|
||||||
|
'topic' => $topic,
|
||||||
|
'data' => json_encode($data),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$ch = curl_init(MERCURE_HUB);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/x-www-form-urlencoded',
|
||||||
|
'Authorization: Bearer ' . mintPublisherJwt(),
|
||||||
|
],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
|
||||||
|
error_log("mercure publish topic=$topic http=$code body=" . ($resp === false ? 'false' : (string) $resp));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mintPublisherJwt(): string
|
||||||
|
{
|
||||||
|
$header = ['alg' => 'HS256', 'typ' => 'JWT'];
|
||||||
|
$payload = ['mercure' => ['publish' => ['*']]];
|
||||||
|
|
||||||
|
$h = b64url(json_encode($header));
|
||||||
|
$p = b64url(json_encode($payload));
|
||||||
|
$s = b64url(hash_hmac('sha256', "$h.$p", MERCURE_KEY, true));
|
||||||
|
|
||||||
|
return "$h.$p.$s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64url(string $bytes): string
|
||||||
|
{
|
||||||
|
return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
29
spike/qt/CMakeLists.txt
Normal file
29
spike/qt/CMakeLists.txt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
project(php_qml_spike LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
|
||||||
|
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Quick QuickControls2 Network Qml)
|
||||||
|
|
||||||
|
qt_standard_project_setup(REQUIRES 6.5)
|
||||||
|
|
||||||
|
qt_add_executable(spike main.cpp)
|
||||||
|
|
||||||
|
qt_add_qml_module(spike
|
||||||
|
URI Spike
|
||||||
|
VERSION 1.0
|
||||||
|
QML_FILES
|
||||||
|
Main.qml
|
||||||
|
Mercure.qml
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(spike PRIVATE
|
||||||
|
Qt6::Core
|
||||||
|
Qt6::Gui
|
||||||
|
Qt6::Quick
|
||||||
|
Qt6::QuickControls2
|
||||||
|
Qt6::Network
|
||||||
|
Qt6::Qml
|
||||||
|
)
|
||||||
128
spike/qt/Main.qml
Normal file
128
spike/qt/Main.qml
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Layouts
|
||||||
|
import QtQuick.Window
|
||||||
|
|
||||||
|
ApplicationWindow {
|
||||||
|
id: window
|
||||||
|
width: 720
|
||||||
|
height: 520
|
||||||
|
visible: true
|
||||||
|
title: "php-qml — Phase 0 spike"
|
||||||
|
|
||||||
|
readonly property string baseUrl: "http://127.0.0.1:8765"
|
||||||
|
readonly property string topic: "app://ping"
|
||||||
|
|
||||||
|
property string status: "starting…"
|
||||||
|
property string lastResponse: ""
|
||||||
|
property string mercureState: "disconnected"
|
||||||
|
|
||||||
|
Mercure {
|
||||||
|
id: mercure
|
||||||
|
url: window.baseUrl + "/.well-known/mercure?topic=" + encodeURIComponent(window.topic)
|
||||||
|
onEventReceived: function(data, id) {
|
||||||
|
log.append("← event " + id.split(":").pop() + " " + data)
|
||||||
|
}
|
||||||
|
onStateChanged: function(s) {
|
||||||
|
window.mercureState = s
|
||||||
|
log.append("· mercure → " + s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: bootProbe
|
||||||
|
interval: 250
|
||||||
|
running: window.status !== "online"
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open("GET", window.baseUrl + "/api/ping")
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
||||||
|
if (window.status !== "online") {
|
||||||
|
window.status = "online"
|
||||||
|
log.append("· backend ready")
|
||||||
|
mercure.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
spacing: 10
|
||||||
|
Layout.fillWidth: true
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 12; height: 12; radius: 6
|
||||||
|
color: window.status === "online" ? "#3ab36c" : "#d89614"
|
||||||
|
}
|
||||||
|
Label { text: "Backend: " + window.status }
|
||||||
|
|
||||||
|
Item { width: 12 }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 12; height: 12; radius: 6
|
||||||
|
color: window.mercureState === "connected" ? "#3ab36c" : "#666"
|
||||||
|
}
|
||||||
|
Label { text: "Mercure: " + window.mercureState }
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "Ping"
|
||||||
|
enabled: window.status === "online"
|
||||||
|
onClicked: {
|
||||||
|
const t0 = Date.now()
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open("GET", window.baseUrl + "/api/ping")
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||||
|
const dt = Date.now() - t0
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
window.lastResponse = xhr.responseText
|
||||||
|
log.append("→ ping " + dt + "ms " + xhr.responseText)
|
||||||
|
} else {
|
||||||
|
log.append("× ping failed status=" + xhr.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: "Last response: " + (window.lastResponse || "—")
|
||||||
|
color: "#888"
|
||||||
|
font.pixelSize: 12
|
||||||
|
Layout.fillWidth: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Frame {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
anchors.fill: parent
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: log
|
||||||
|
readOnly: true
|
||||||
|
wrapMode: TextArea.Wrap
|
||||||
|
font.family: "monospace"
|
||||||
|
font.pixelSize: 12
|
||||||
|
placeholderText: "events will appear here…"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
spike/qt/Mercure.qml
Normal file
84
spike/qt/Mercure.qml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import QtQuick
|
||||||
|
|
||||||
|
// Tiny SSE client implemented in pure QML.
|
||||||
|
// Caveat: relies on QML's XMLHttpRequest delivering partial responses
|
||||||
|
// during readyState === LOADING. If that proves unreliable, this gets
|
||||||
|
// replaced by a C++ class in Phase 1 (per PLAN.md §7, MercureClient).
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string url: ""
|
||||||
|
property bool active: false
|
||||||
|
|
||||||
|
signal eventReceived(string data, string id)
|
||||||
|
signal stateChanged(string state)
|
||||||
|
|
||||||
|
property var _xhr: null
|
||||||
|
property int _offset: 0
|
||||||
|
property string _accumulated: ""
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (active) return
|
||||||
|
_offset = 0
|
||||||
|
_accumulated = ""
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open("GET", url)
|
||||||
|
xhr.setRequestHeader("Accept", "text/event-stream")
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === XMLHttpRequest.LOADING ||
|
||||||
|
xhr.readyState === XMLHttpRequest.DONE) {
|
||||||
|
const text = xhr.responseText
|
||||||
|
if (text.length > _offset) {
|
||||||
|
const fresh = text.substring(_offset)
|
||||||
|
_offset = text.length
|
||||||
|
_ingest(fresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||||
|
active = false
|
||||||
|
stateChanged("disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.send()
|
||||||
|
_xhr = xhr
|
||||||
|
active = true
|
||||||
|
stateChanged("connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (!active) return
|
||||||
|
if (_xhr) _xhr.abort()
|
||||||
|
active = false
|
||||||
|
stateChanged("disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ingest(chunk) {
|
||||||
|
_accumulated += chunk
|
||||||
|
const parts = _accumulated.split("\n\n")
|
||||||
|
_accumulated = parts.pop()
|
||||||
|
for (let i = 0; i < parts.length; ++i) {
|
||||||
|
_emit(parts[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _emit(message) {
|
||||||
|
const lines = message.split("\n")
|
||||||
|
let id = ""
|
||||||
|
let dataLines = []
|
||||||
|
for (let i = 0; i < lines.length; ++i) {
|
||||||
|
const line = lines[i]
|
||||||
|
if (line.length === 0 || line.charAt(0) === ":") continue
|
||||||
|
const colon = line.indexOf(":")
|
||||||
|
if (colon < 0) continue
|
||||||
|
const field = line.substring(0, colon)
|
||||||
|
let value = line.substring(colon + 1)
|
||||||
|
if (value.length > 0 && value.charAt(0) === " ") value = value.substring(1)
|
||||||
|
if (field === "data") dataLines.push(value)
|
||||||
|
else if (field === "id") id = value
|
||||||
|
}
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
eventReceived(dataLines.join("\n"), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
spike/qt/main.cpp
Normal file
72
spike/qt/main.cpp
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
#include <csignal>
|
||||||
|
#include <sys/prctl.h>
|
||||||
|
|
||||||
|
// Resolve the spike root by walking up from the running binary
|
||||||
|
// until we find a directory containing Caddyfile + bin/frankenphp.
|
||||||
|
static QString resolveSpikeRoot()
|
||||||
|
{
|
||||||
|
QDir d(QCoreApplication::applicationDirPath());
|
||||||
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
if (QFileInfo(d.filePath("Caddyfile")).exists() &&
|
||||||
|
QFileInfo(d.filePath("bin/frankenphp")).exists()) {
|
||||||
|
return d.absolutePath();
|
||||||
|
}
|
||||||
|
if (!d.cdUp()) break;
|
||||||
|
}
|
||||||
|
qWarning() << "Could not locate spike root from"
|
||||||
|
<< QCoreApplication::applicationDirPath();
|
||||||
|
return QCoreApplication::applicationDirPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGTERM / SIGINT → graceful Qt quit so aboutToQuit cleanup runs.
|
||||||
|
static void installSignalHandlers()
|
||||||
|
{
|
||||||
|
auto handler = +[](int) { QCoreApplication::quit(); };
|
||||||
|
std::signal(SIGTERM, handler);
|
||||||
|
std::signal(SIGINT, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QGuiApplication app(argc, argv);
|
||||||
|
installSignalHandlers();
|
||||||
|
|
||||||
|
const QString spikeRoot = resolveSpikeRoot();
|
||||||
|
qInfo() << "spike root:" << spikeRoot;
|
||||||
|
|
||||||
|
QProcess franken;
|
||||||
|
franken.setProgram(spikeRoot + "/bin/frankenphp");
|
||||||
|
franken.setArguments({"run", "--config", spikeRoot + "/Caddyfile"});
|
||||||
|
franken.setWorkingDirectory(spikeRoot);
|
||||||
|
franken.setProcessChannelMode(QProcess::ForwardedChannels);
|
||||||
|
|
||||||
|
// Linux belt-and-suspenders: ask the kernel to SIGTERM the child
|
||||||
|
// when this process dies, regardless of how (crash, kill -9, etc.).
|
||||||
|
franken.setChildProcessModifier([] {
|
||||||
|
prctl(PR_SET_PDEATHSIG, SIGTERM);
|
||||||
|
});
|
||||||
|
|
||||||
|
franken.start();
|
||||||
|
|
||||||
|
QObject::connect(&app, &QCoreApplication::aboutToQuit, [&]() {
|
||||||
|
if (franken.state() == QProcess::NotRunning) return;
|
||||||
|
franken.terminate();
|
||||||
|
if (!franken.waitForFinished(2000)) franken.kill();
|
||||||
|
});
|
||||||
|
|
||||||
|
QQmlApplicationEngine engine;
|
||||||
|
engine.loadFromModule("Spike", "Main");
|
||||||
|
|
||||||
|
if (engine.rootObjects().isEmpty()) return -1;
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
22
spike/run.sh
Executable file
22
spike/run.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build (if needed) and run the Phase 0 spike.
|
||||||
|
# Toolchain expected on PATH: cmake, qmake6 (or qmake from qt6-base-devel), g++.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SPIKE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BUILD_DIR="$SPIKE_DIR/build"
|
||||||
|
BIN="$BUILD_DIR/spike"
|
||||||
|
|
||||||
|
if [ ! -x "$SPIKE_DIR/bin/frankenphp" ]; then
|
||||||
|
echo "missing $SPIKE_DIR/bin/frankenphp — run download from spike/README.md" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$BIN" ]; then
|
||||||
|
echo "==> configuring + building Qt host"
|
||||||
|
cmake -S "$SPIKE_DIR/qt" -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build "$BUILD_DIR" --parallel
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> launching spike"
|
||||||
|
exec "$BIN"
|
||||||
Reference in New Issue
Block a user