4 Commits
Author SHA1 Message Date
magdevandClaude Opus 4.7 4d6b9fde2c bundled: disconnect child signals before terminate() to prevent restart-during-shutdown
CI / Quality (push) Successful in 5m17s
Release / Linux AppImage (push) Successful in 5m32s
teardownChild called terminate() then waitForFinished(2000), then
disconnected the QProcess signals. But waitForFinished pumps a local
event loop — when frankenphp exited inside that wait, QProcess::finished
fired synchronously, ran onChildFinished as the crash-supervisor's
restart path, and spawned a brand-new frankenphp child during
shutdown. That child's QProcess was then destroyed mid-spawn during
stack unwinding, producing the "QProcess: Destroyed while process is
still running" warning the bundled-supervisor.sh test catches.

Fix: disconnect first, then terminate. Severing signals before the
wait turns terminate() into the synchronous reap it should always
have been; onChildFinished can't run for a process we're explicitly
tearing down.

Local integration test passes clean — both the cache-baked-mount-path
relaunch and the graceful-shutdown assertion go through without the
warning or any orphan frankenphp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:23:33 +02:00
magdevandClaude Opus 4.7 ed4db00a62 bundled: relay SIGTERM/SIGINT into Qt's quit() via self-pipe
CI / Quality (push) Failing after 5m46s
Release / Linux AppImage (push) Successful in 6m15s
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>
2026-05-03 18:55:23 +02:00
magdevandClaude Opus 4.7 ee68561bae bridge: restore autoconfigure inside the when@dev maker block
CI / Quality (push) Failing after 5m51s
Release / Linux AppImage (push) Has been cancelled
The previous fix (c78d471) moved the maker qml_path injection into
when@dev: but didn't repeat _defaults inside it. when@<env> opens a
fresh services block with no inheritance, so the explicit Maker
definitions lost autowire/autoconfigure — and with autoconfigure off,
maker-bundle's `maker.command` tag was never applied. Symptom in CI:
`make:bridge:resource` silently disappears from `bin/console list`
while `make:bridge:command` (registered by the glob, no override)
keeps working. Snapshot test failed with "Command 'make:bridge:resource'
is not defined".

Fix: add _defaults inside the when@dev block. Snapshot test passes
locally; prod cache:clear in --no-dev still compiles clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:43:11 +02:00
magdevandClaude Opus 4.7 c78d471368 bridge: scope maker qml_path injection to when@dev
CI / Quality (push) Failing after 4m0s
Release / Linux AppImage (push) Successful in 5m30s
The v0.1.2 build broke the staging-symfony container compile: explicit
top-level `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` blocks
forced ResolveClassPass to load AbstractMaker, which is excluded by
`composer install --no-dev`. The glob alone tolerates the missing
parent (FileLoader silently drops classes that fail class_exists), but
explicit blocks bypass that check.

Fix: keep v0.1.1's plain glob untouched; move the qml_path argument
overrides into a `when@dev:` envelope that prod/no-dev compiles never
touch. Dev builds still resolve the bound parameter (verified via
debug:container — Argument value `../qml/`); prod cache:clear no
longer aborts on missing AbstractMaker; integration-bundled passes
end-to-end locally.

No public-API change; release CI fix only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:48:51 +02:00
3 changed files with 98 additions and 8 deletions

No files matched your search

+5 -1
View File
@@ -145,7 +145,11 @@ fi
# fresh-mount situation), assert /healthz comes back up. # fresh-mount situation), assert /healthz comes back up.
step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)" step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)"
kill -TERM "$PID" 2>/dev/null || true kill -TERM "$PID" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do # 3s grace: teardownChild itself waits up to 2s for frankenphp to finish
# after sending it SIGTERM, so the host can take ~2.x seconds to exit
# cleanly. A 2s loop here was right at the boundary and triggered the
# fallback SIGKILL on slower runners.
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
kill -0 "$PID" 2>/dev/null || break kill -0 "$PID" 2>/dev/null || break
sleep 0.2 sleep 0.2
done done
+28 -6
View File
@@ -12,10 +12,32 @@ services:
arguments: arguments:
$expectedToken: '%env(default::BRIDGE_TOKEN)%' $expectedToken: '%env(default::BRIDGE_TOKEN)%'
PhpQml\Bridge\Maker\BridgeResourceMaker: # Maker classes extend symfony/maker-bundle's AbstractMaker, which is a
arguments: # require-dev dependency. In `composer install --no-dev` builds (the
$qmlPath: '%bridge.qml_path%' # staging-symfony tree the AppImage is assembled from) AbstractMaker is
# absent: PHP fails to autoload BridgeResourceMaker etc., so the glob
# above silently drops them — that's fine. But a top-level explicit
# `services.PhpQml\Bridge\Maker\BridgeResourceMaker:` block forces
# ResolveClassPass to load the class regardless of dev/prod, which then
# crashes the prod container compile. Scope the qml_path injection to
# `when@dev:` so prod builds never touch these definitions.
when@dev:
services:
# _defaults must be repeated here — `when@<env>` opens a fresh
# services block, so the top-level autowire/autoconfigure don't
# carry over. Without autoconfigure the explicit definitions
# below would lose maker-bundle's `maker.command` tag, and
# `make:bridge:resource` would silently disappear from the
# console while `make:bridge:command` (registered by the glob,
# no override) keeps working.
_defaults:
autowire: true
autoconfigure: true
PhpQml\Bridge\Maker\BridgeWindowMaker: PhpQml\Bridge\Maker\BridgeResourceMaker:
arguments: arguments:
$qmlPath: '%bridge.qml_path%' $qmlPath: '%bridge.qml_path%'
PhpQml\Bridge\Maker\BridgeWindowMaker:
arguments:
$qmlPath: '%bridge.qml_path%'
+65 -1
View File
@@ -11,12 +11,15 @@
#include <QProcessEnvironment> #include <QProcessEnvironment>
#include <QQmlEngine> #include <QQmlEngine>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QSocketNotifier>
#include <QStandardPaths> #include <QStandardPaths>
#include <QTimer> #include <QTimer>
#include <QUrl> #include <QUrl>
#include <csignal> #include <csignal>
#include <fcntl.h>
#include <sys/prctl.h> #include <sys/prctl.h>
#include <unistd.h>
namespace PhpQml::Bridge { namespace PhpQml::Bridge {
@@ -28,6 +31,53 @@ constexpr int kProbeIntervalMs = 5000;
constexpr int kProbeTimeoutMs = 2000; constexpr int kProbeTimeoutMs = 2000;
constexpr int kMigrateTimeoutMs = 60000; constexpr int kMigrateTimeoutMs = 60000;
constexpr int kBootProbeMaxMs = 10000; constexpr int kBootProbeMaxMs = 10000;
// Self-pipe used to relay SIGTERM/SIGINT into the Qt event loop. The
// signal handler can only call async-signal-safe functions, so it just
// writes one byte to the pipe; a QSocketNotifier on the read end picks
// it up in the main thread and calls QCoreApplication::quit(), which
// fires aboutToQuit → teardownChild → frankenphp gets a clean SIGTERM
// while the event loop is still running. Without this, `kill -TERM`
// to the host bypasses Qt entirely and the supervisor never gets to
// reap the child.
int g_signalPipe[2] = {-1, -1};
extern "C" void shutdownSignalHandler(int signum)
{
const char b = static_cast<char>(signum & 0xff);
// write() is async-signal-safe; failure is ignored — best effort.
[[maybe_unused]] auto _ = ::write(g_signalPipe[1], &b, 1);
}
void installShutdownSignalRelay()
{
if (g_signalPipe[0] != -1) return; // already installed
if (::pipe2(g_signalPipe, O_CLOEXEC | O_NONBLOCK) != 0) {
qCWarning(lcBundled) << "shutdown signal pipe creation failed; SIGTERM will not run teardownChild cleanly";
return;
}
// QSocketNotifier needs a parent that outlives any signal delivery.
// QCoreApplication is the natural anchor.
auto* notifier = new QSocketNotifier(g_signalPipe[0], QSocketNotifier::Read,
QCoreApplication::instance());
QObject::connect(notifier, &QSocketNotifier::activated, [](QSocketDescriptor) {
char buf[16];
while (::read(g_signalPipe[0], buf, sizeof(buf)) > 0) {
// drain — content is just the signum, we don't care which
}
if (auto* app = QCoreApplication::instance()) {
app->quit();
}
});
struct sigaction sa{};
sa.sa_handler = &shutdownSignalHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
::sigaction(SIGTERM, &sa, nullptr);
::sigaction(SIGINT, &sa, nullptr);
}
} // namespace } // namespace
BackendConnection::BackendConnection(QObject* parent) BackendConnection::BackendConnection(QObject* parent)
@@ -46,9 +96,16 @@ BackendConnection::BackendConnection(QObject* parent)
// starts unwinding the stack. Without this, teardownChild only runs from // starts unwinding the stack. Without this, teardownChild only runs from
// ~BackendConnection — by then the QQmlEngine is already mid-destruction // ~BackendConnection — by then the QQmlEngine is already mid-destruction
// and Qt warns "QProcess: Destroyed while process is still running". // and Qt warns "QProcess: Destroyed while process is still running".
//
// aboutToQuit only fires when something *calls* quit() — Qt does not
// install a default SIGTERM handler. installShutdownSignalRelay() bridges
// SIGTERM/SIGINT into a quit() call so `kill -TERM` from a service
// manager / launcher / test harness goes through the same teardown path
// as a window-close.
if (QCoreApplication::instance()) { if (QCoreApplication::instance()) {
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit,
this, &BackendConnection::teardownChild); this, &BackendConnection::teardownChild);
installShutdownSignalRelay();
} }
const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL")); const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));
@@ -323,6 +380,14 @@ bool BackendConnection::spawnChild(QString* errorOut)
void BackendConnection::teardownChild() void BackendConnection::teardownChild()
{ {
if (!m_child) return; if (!m_child) return;
// Disconnect *before* terminating: waitForFinished() pumps a local event
// loop, so QProcess::finished would fire synchronously inside that wait,
// run onChildFinished as the crash-supervisor restart path, and spawn a
// brand-new frankenphp child during shutdown — the new QProcess then
// gets destroyed mid-spawn during stack unwinding and Qt warns
// "Destroyed while process is still running". Severing signals first
// turns terminate() into the synchronous reap it should always have been.
disconnect(m_child, nullptr, this, nullptr);
if (m_child->state() != QProcess::NotRunning) { if (m_child->state() != QProcess::NotRunning) {
m_child->terminate(); m_child->terminate();
if (!m_child->waitForFinished(2000)) { if (!m_child->waitForFinished(2000)) {
@@ -330,7 +395,6 @@ void BackendConnection::teardownChild()
m_child->waitForFinished(1000); m_child->waitForFinished(1000);
} }
} }
disconnect(m_child, nullptr, this, nullptr);
m_child->deleteLater(); m_child->deleteLater();
m_child = nullptr; m_child = nullptr;
m_childLogBuffer.clear(); m_childLogBuffer.clear();