From ed4db00a62a704533bf4830bb2def7075a1fa63b Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 3 May 2026 18:55:23 +0200 Subject: [PATCH] bundled: relay SIGTERM/SIGINT into Qt's quit() via self-pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- examples/todo/tests/bundled-supervisor.sh | 6 ++- framework/qml/src/BackendConnection.cpp | 57 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/examples/todo/tests/bundled-supervisor.sh b/examples/todo/tests/bundled-supervisor.sh index 06b215c..007a5ac 100755 --- a/examples/todo/tests/bundled-supervisor.sh +++ b/examples/todo/tests/bundled-supervisor.sh @@ -145,7 +145,11 @@ fi # fresh-mount situation), assert /healthz comes back up. step "tear down + relaunch from fresh staging (regression: cache-baked-mount-path)" 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 sleep 0.2 done diff --git a/framework/qml/src/BackendConnection.cpp b/framework/qml/src/BackendConnection.cpp index 7982f6a..8838689 100644 --- a/framework/qml/src/BackendConnection.cpp +++ b/framework/qml/src/BackendConnection.cpp @@ -11,12 +11,15 @@ #include #include #include +#include #include #include #include #include +#include #include +#include namespace PhpQml::Bridge { @@ -28,6 +31,53 @@ constexpr int kProbeIntervalMs = 5000; constexpr int kProbeTimeoutMs = 2000; constexpr int kMigrateTimeoutMs = 60000; 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(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 BackendConnection::BackendConnection(QObject* parent) @@ -46,9 +96,16 @@ BackendConnection::BackendConnection(QObject* parent) // starts unwinding the stack. Without this, teardownChild only runs from // ~BackendConnection — by then the QQmlEngine is already mid-destruction // 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()) { connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &BackendConnection::teardownChild); + installShutdownSignalRelay(); } const QString explicitUrl = QString::fromUtf8(qgetenv("BRIDGE_URL"));