Phase 4a sub-commit 1: bundled-mode startup in BackendConnection

Auto-detected on construction:

  - BRIDGE_URL env set → dev mode (today's behaviour, unchanged).
  - BRIDGE_URL unset → bundled mode: BackendConnection now
    1. Resolves the user app data dir (QStandardPaths::AppDataLocation,
       ~/.local/share/<org>/<app> on Linux) and ensures var/, var/log/,
       var/cache/ exist there.
    2. Generates a per-session 32-byte URL-safe token and a 48-byte
       Mercure JWT secret.
    3. Runs `frankenphp php-cli bin/console doctrine:migrations:migrate -n`
       against the user's DATABASE_URL with a 60s timeout.
    4. Spawns FrankenPHP via QProcess with BRIDGE_TOKEN/MERCURE_*/PORT
       in the env, prctl(PR_SET_PDEATHSIG, SIGTERM) on the child, and
       a supervisor that re-spawns up to 5 times on unexpected exit.
       Each restart fires tokenRotated(newToken).

Path resolution defaults to applicationDirPath() + bin/frankenphp,
applicationDirPath() + symfony, applicationDirPath() + Caddyfile, with
both `/../share/<app>/...` and `/../usr/share/<app>/...` fallbacks for
AppImage-style layouts. All three are overridable via
BRIDGE_FRANKENPHP_BIN / BRIDGE_SYMFONY_DIR / BRIDGE_CADDYFILE env vars.

Caddyfiles in skeleton + example now use {$VAR:default} substitution
for PORT and the Mercure JWT keys, so the same Caddyfile works in both
modes. Dev defaults match symfony/.env.

restart() in bundled mode re-spawns the child (resets the supervisor
counter); in dev mode it stays a probe-only no-op.

Smoke-tested locally with `BRIDGE_FRANKENPHP_BIN=…  BRIDGE_SYMFONY_DIR=…
BRIDGE_CADDYFILE=…  ./build/qml/todo` (no BRIDGE_URL): bundled mode
created ~/.local/share/php-qml/todo/var/data.sqlite, ran the migration,
spawned FrankenPHP, served /healthz, accepted a POST /api/todos with
the per-session bearer. Dev mode (`make dev`) still works unchanged.

Includes a `phpqml.bridge.bundled` Q_LOGGING_CATEGORY so failures
surface to the user; enable with QT_LOGGING_RULES='phpqml.bridge.bundled.*=true'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:00:13 +02:00
parent ccd2f1b27c
commit a1cc06abbb
4 changed files with 396 additions and 53 deletions

View File

@@ -1,53 +1,341 @@
#include "BackendConnection.h"
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QLoggingCategory>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QProcessEnvironment>
#include <QQmlEngine>
#include <QRandomGenerator>
#include <QStandardPaths>
#include <QTimer>
#include <QUrl>
#include <csignal>
#include <sys/prctl.h>
namespace PhpQml::Bridge {
Q_LOGGING_CATEGORY(lcBundled, "phpqml.bridge.bundled")
namespace {
constexpr int kInitialProbeMs = 0;
constexpr int kProbeIntervalMs = 5000;
constexpr int kProbeTimeoutMs = 2000;
constexpr int kInitialProbeMs = 0;
constexpr int kProbeIntervalMs = 5000;
constexpr int kProbeTimeoutMs = 2000;
constexpr int kMigrateTimeoutMs = 60000;
constexpr int kBootProbeMaxMs = 10000;
} // namespace
BackendConnection::BackendConnection(QObject* parent)
: QObject(parent)
, m_appName(QCoreApplication::applicationName().isEmpty()
? QStringLiteral("php-qml-app")
: QCoreApplication::applicationName())
, m_nam(new QNetworkAccessManager(this))
, m_retryTimer(new QTimer(this))
{
m_url = QString::fromUtf8(qgetenv("BRIDGE_URL"));
m_token = QString::fromUtf8(qgetenv("BRIDGE_TOKEN"));
if (m_url.isEmpty()) {
// Dev-mode fallback: matches the spike's hardcoded port and
// documents the convention. See PLAN.md §11 *Open Questions*
// (system FrankenPHP collision on :8080).
m_url = QStringLiteral("http://127.0.0.1:8765");
}
m_retryTimer->setSingleShot(false);
m_retryTimer->setInterval(kProbeIntervalMs);
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
m_retryTimer->start();
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
if (!explicitUrl.isEmpty()) {
m_mode = Mode::Dev;
m_url = explicitUrl;
m_token = QString::fromUtf8(qgetenv("BRIDGE_TOKEN"));
QTimer::singleShot(0, this, &BackendConnection::initDevMode);
} else {
m_mode = Mode::Bundled;
// Bundled init runs after the QApplication event loop is up so
// QStandardPaths and QProcess work correctly.
QTimer::singleShot(0, this, &BackendConnection::initBundledMode);
}
}
BackendConnection::~BackendConnection() = default;
BackendConnection::~BackendConnection()
{
teardownChild();
}
BackendConnection* BackendConnection::create(QQmlEngine* engine, QJSEngine*)
{
return new BackendConnection(engine);
}
void BackendConnection::initDevMode()
{
if (m_url.isEmpty()) {
m_url = QStringLiteral("http://127.0.0.1:8765");
}
emit urlChanged();
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
m_retryTimer->start();
}
void BackendConnection::initBundledMode()
{
const QString fpBin = resolveFrankenphpBin();
const QString symfony = resolveSymfonyDir();
const QString caddyCfg = resolveCaddyfilePath();
if (!QFileInfo(fpBin).isExecutable()) {
const QString msg = QStringLiteral("bundled: frankenphp not found at %1 (override with BRIDGE_FRANKENPHP_BIN)").arg(fpBin);
qCWarning(lcBundled) << msg;
setError(msg);
setState(ConnectionState::Offline);
return;
}
if (symfony.isEmpty() || !QFileInfo(symfony + "/public/index.php").exists()) {
const QString msg = QStringLiteral("bundled: symfony app not found near %1 (override with BRIDGE_SYMFONY_DIR)").arg(QCoreApplication::applicationDirPath());
qCWarning(lcBundled) << msg;
setError(msg);
setState(ConnectionState::Offline);
return;
}
if (caddyCfg.isEmpty() || !QFileInfo(caddyCfg).exists()) {
const QString msg = QStringLiteral("bundled: Caddyfile not found near %1 (override with BRIDGE_CADDYFILE)").arg(QCoreApplication::applicationDirPath());
qCWarning(lcBundled) << msg;
setError(msg);
setState(ConnectionState::Offline);
return;
}
qCInfo(lcBundled) << "frankenphp:" << fpBin;
qCInfo(lcBundled) << "symfony:" << symfony;
qCInfo(lcBundled) << "caddyfile:" << caddyCfg;
m_dataDir = userDataDir();
QDir().mkpath(m_dataDir + "/var/log");
QDir().mkpath(m_dataDir + "/var/cache");
setToken(randomSecret(32));
m_jwtSecret = randomSecret(48); // ≥256 bits for lcobucci/jwt
setUrl(QStringLiteral("http://127.0.0.1:%1").arg(m_port));
if (!runMigrations()) {
return; // setError already invoked
}
QString spawnErr;
if (!spawnChild(&spawnErr)) {
setError(spawnErr);
setState(ConnectionState::Offline);
return;
}
setState(ConnectionState::Connecting);
QTimer::singleShot(kInitialProbeMs, this, &BackendConnection::probe);
m_retryTimer->start();
}
QString BackendConnection::resolveFrankenphpBin() const
{
const QByteArray override = qgetenv("BRIDGE_FRANKENPHP_BIN");
if (!override.isEmpty()) return QString::fromUtf8(override);
return QCoreApplication::applicationDirPath() + QStringLiteral("/bin/frankenphp");
}
QString BackendConnection::resolveSymfonyDir() const
{
const QByteArray override = qgetenv("BRIDGE_SYMFONY_DIR");
if (!override.isEmpty()) return QString::fromUtf8(override);
const QString here = QCoreApplication::applicationDirPath();
const QStringList candidates = {
here + QStringLiteral("/symfony"),
here + QStringLiteral("/../symfony"),
here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/symfony"),
here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/symfony"),
};
for (const QString& c : candidates) {
if (QFileInfo(c + "/public/index.php").exists()) {
return QDir(c).absolutePath();
}
}
return {};
}
QString BackendConnection::resolveCaddyfilePath() const
{
const QByteArray override = qgetenv("BRIDGE_CADDYFILE");
if (!override.isEmpty()) return QString::fromUtf8(override);
const QString here = QCoreApplication::applicationDirPath();
const QStringList candidates = {
here + QStringLiteral("/Caddyfile"),
here + QStringLiteral("/../Caddyfile"),
here + QStringLiteral("/../share/") + m_appName + QStringLiteral("/Caddyfile"),
here + QStringLiteral("/../usr/share/") + m_appName + QStringLiteral("/Caddyfile"),
};
for (const QString& c : candidates) {
if (QFileInfo(c).exists()) {
return QFileInfo(c).absoluteFilePath();
}
}
return {};
}
QString BackendConnection::userDataDir() const
{
QString p = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
if (p.isEmpty()) {
p = QDir::homePath() + QStringLiteral("/.local/share/") + m_appName;
}
return p;
}
QString BackendConnection::databaseUrl() const
{
return QStringLiteral("sqlite:///%1/var/data.sqlite").arg(m_dataDir);
}
bool BackendConnection::runMigrations()
{
QProcess proc;
proc.setProgram(resolveFrankenphpBin());
proc.setArguments({
QStringLiteral("php-cli"),
resolveSymfonyDir() + QStringLiteral("/bin/console"),
QStringLiteral("doctrine:migrations:migrate"),
QStringLiteral("-n"),
});
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod"));
env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0"));
env.insert(QStringLiteral("APP_SECRET"), QStringLiteral("bundled-mode-migrations-do-not-need-this"));
env.insert(QStringLiteral("DATABASE_URL"), databaseUrl());
proc.setProcessEnvironment(env);
proc.setWorkingDirectory(resolveSymfonyDir());
proc.setProcessChannelMode(QProcess::MergedChannels);
qCInfo(lcBundled) << "running migrations against" << databaseUrl();
proc.start();
if (!proc.waitForFinished(kMigrateTimeoutMs)) {
proc.kill();
const QString msg = QStringLiteral("bundled: migrations timed out");
qCWarning(lcBundled) << msg;
setError(msg);
setState(ConnectionState::Offline);
return false;
}
if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
const QString msg = QStringLiteral("bundled: migrations failed (exit %1)\n%2")
.arg(proc.exitCode())
.arg(QString::fromUtf8(proc.readAll()));
qCWarning(lcBundled).noquote() << msg;
setError(msg);
setState(ConnectionState::Offline);
return false;
}
qCInfo(lcBundled) << "migrations OK";
return true;
}
bool BackendConnection::spawnChild(QString* errorOut)
{
teardownChild();
m_child = new QProcess(this);
m_child->setProgram(resolveFrankenphpBin());
m_child->setArguments({
QStringLiteral("run"),
QStringLiteral("--config"),
resolveCaddyfilePath(),
});
m_child->setWorkingDirectory(resolveSymfonyDir());
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
env.insert(QStringLiteral("APP_ENV"), QStringLiteral("prod"));
env.insert(QStringLiteral("APP_DEBUG"), QStringLiteral("0"));
env.insert(QStringLiteral("APP_SECRET"), randomSecret(16));
env.insert(QStringLiteral("BRIDGE_TOKEN"), m_token);
env.insert(QStringLiteral("DATABASE_URL"), databaseUrl());
env.insert(QStringLiteral("PORT"), QString::number(m_port));
env.insert(QStringLiteral("MERCURE_URL"), m_url + QStringLiteral("/.well-known/mercure"));
env.insert(QStringLiteral("MERCURE_PUBLIC_URL"), m_url + QStringLiteral("/.well-known/mercure"));
env.insert(QStringLiteral("MERCURE_JWT_SECRET"), m_jwtSecret);
env.insert(QStringLiteral("MERCURE_PUBLISHER_JWT_KEY"), m_jwtSecret);
env.insert(QStringLiteral("MERCURE_SUBSCRIBER_JWT_KEY"), m_jwtSecret);
m_child->setProcessEnvironment(env);
m_child->setProcessChannelMode(QProcess::ForwardedChannels);
// Linux: kernel kills the child if the parent dies for any reason.
m_child->setChildProcessModifier([] {
prctl(PR_SET_PDEATHSIG, SIGTERM);
});
connect(m_child, &QProcess::finished,
this, &BackendConnection::onChildFinished);
qCInfo(lcBundled) << "spawning frankenphp on port" << m_port;
m_child->start();
if (!m_child->waitForStarted(5000)) {
const QString err = QStringLiteral("bundled: failed to start frankenphp: ") + m_child->errorString();
qCWarning(lcBundled) << err;
if (errorOut) *errorOut = err;
teardownChild();
return false;
}
return true;
}
void BackendConnection::teardownChild()
{
if (!m_child) return;
if (m_child->state() != QProcess::NotRunning) {
m_child->terminate();
if (!m_child->waitForFinished(2000)) {
m_child->kill();
m_child->waitForFinished(1000);
}
}
disconnect(m_child, nullptr, this, nullptr);
m_child->deleteLater();
m_child = nullptr;
}
void BackendConnection::onChildFinished(int exitCode, QProcess::ExitStatus status)
{
Q_UNUSED(status);
if (m_mode != Mode::Bundled) return;
setError(QStringLiteral("bundled: frankenphp exited (code=%1); supervisor restart").arg(exitCode));
if (m_supervisorRetries >= kMaxSupervisorRetries) {
setState(ConnectionState::Offline);
return;
}
++m_supervisorRetries;
// New session secret on every restart.
setToken(randomSecret(32));
emit tokenRotated(m_token);
QString err;
if (!spawnChild(&err)) {
setError(err);
setState(ConnectionState::Offline);
return;
}
setState(ConnectionState::Reconnecting);
}
void BackendConnection::restart()
{
// No-op in dev mode (Phase 1). Phase 4 re-spawns the bundled child.
if (m_mode == Mode::Bundled) {
m_supervisorRetries = 0;
QString err;
if (!spawnChild(&err)) {
setError(err);
setState(ConnectionState::Offline);
return;
}
setState(ConnectionState::Connecting);
}
probe();
}
@@ -63,7 +351,8 @@ void BackendConnection::probe()
}
m_pendingReply = m_nam->get(req);
connect(m_pendingReply, &QNetworkReply::finished, this, &BackendConnection::onProbeFinished);
connect(m_pendingReply, &QNetworkReply::finished,
this, &BackendConnection::onProbeFinished);
}
void BackendConnection::onProbeFinished()
@@ -88,16 +377,19 @@ void BackendConnection::onProbeFinished()
if (ok) {
setError(QString());
m_firstFailureSinceOnline.invalidate();
m_supervisorRetries = 0;
setState(ConnectionState::Online);
return;
}
// Probe failed. Decide between Reconnecting and Offline based on how
// long we've been failing since the last Online. (PLAN.md §5.)
if (!m_firstFailureSinceOnline.isValid()) {
m_firstFailureSinceOnline.start();
}
if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs)) {
// In bundled mode, ride the supervisor: hint Reconnecting until the
// child re-spawns or we exhaust retries (which sets Offline directly).
const int boot = (m_mode == Mode::Bundled) ? kBootProbeMaxMs : 0;
if (m_firstFailureSinceOnline.hasExpired(m_offlineThresholdMs + boot)) {
setState(ConnectionState::Offline);
} else {
setState(ConnectionState::Reconnecting);
@@ -118,4 +410,26 @@ void BackendConnection::setError(const QString& msg)
emit errorChanged();
}
void BackendConnection::setUrl(const QString& url)
{
if (m_url == url) return;
m_url = url;
emit urlChanged();
}
void BackendConnection::setToken(const QString& token)
{
if (m_token == token) return;
m_token = token;
emit tokenChanged();
}
QString BackendConnection::randomSecret(int bytes)
{
QByteArray buf(bytes, Qt::Uninitialized);
auto* gen = QRandomGenerator::system();
gen->generate(buf.begin(), buf.end());
return QString::fromLatin1(buf.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
} // namespace PhpQml::Bridge