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"));