Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d6b9fde2c | |||
| ed4db00a62 | |||
| ee68561bae | |||
| c78d471368 | |||
| 8b2fc4dd06 | |||
| 0cceefc890 | |||
| 9f524104b9 | |||
| f132c3c9b6 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -10,6 +10,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
||||
|
||||
- (none yet — next changes land here)
|
||||
|
||||
## [0.1.2] — 2026-05-03
|
||||
|
||||
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bundled supervisor: clean child shutdown.** `BackendConnection`'s destructor was the only path that called `teardownChild()`, but it ran during stack unwinding *after* `app.exec()` returned — by then the Qt event loop was already mid-shutdown and `QProcess::waitForFinished` couldn't reliably reap the child. Symptom: Qt logged `QProcess: Destroyed while process ("...frankenphp") is still running`, frankenphp + its PHP workers became orphans. The constructor now also connects `QCoreApplication::aboutToQuit` → `teardownChild`, so the child is SIGTERM'd while the event loop is still active. The bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning + no orphan frankenphp under the host's PGID after SIGTERM).
|
||||
- **`bridge.qml_path` is now actually configurable.** The `BridgeResourceMaker` and `BridgeWindowMaker` docstrings claimed the QML scaffold path was settable via the bundle's `qml_path` option, but the bundle's `configure()` was empty and the constructor default (`'../qml/'`) was the only knob. `BridgeBundle::configure` now defines a `qml_path` scalar node; `loadExtension` exposes it as the `bridge.qml_path` container parameter; `services.yaml` binds it into both makers. Apps can override with `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`. Default unchanged.
|
||||
- **`SessionAuthenticator`: problem+json on the entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default `AuthenticationEntryPointInterface::start` fired for *no-token* requests, returning a Form-flavoured 302/401 with the wrong shape for QML's `RestClient` error mapping. The authenticator now implements `AuthenticationEntryPointInterface` and routes both paths through a shared `problemJson()` helper so QML sees one error shape regardless of which firewall path was taken. New test covers the entry-point response.
|
||||
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already guarded with `isMainRequest()`, but `onTerminate` cleared unconditionally — a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. FrankenPHP worker mode does not currently emit sub-requests so the user-visible impact is nil, but the asymmetry was a defensive bug.
|
||||
|
||||
## [0.1.1] — 2026-05-03
|
||||
|
||||
Bugfix release closing the four follow-ups identified during the v0.1.0 shakedown. No new public API surface; `/healthz` response gains an additive `bundle` field (existing JSON consumers ignore unknown keys).
|
||||
@@ -62,6 +73,7 @@ First public preview. Phases 0 through 4a in PLAN.md are complete plus the Phase
|
||||
- The bundle ships without `composer.lock` (it's a library); the skeleton and the todo example carry their own.
|
||||
- Licensed under **LGPL-3.0-or-later** (`LICENSE` + `LICENSE.GPL` at the repo root). Chosen to align with Qt 6's LGPLv3 licensing — see PLAN.md §12 for the relinkability obligations the AppImage build already honours.
|
||||
|
||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.1.1...HEAD
|
||||
[Unreleased]: https://src.bundespruefstelle.ch/magdev/php-qml/compare/v0.1.2...HEAD
|
||||
[0.1.2]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.2
|
||||
[0.1.1]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.1
|
||||
[0.1.0]: https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0
|
||||
|
||||
30
PLAN.md
30
PLAN.md
@@ -1,6 +1,6 @@
|
||||
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
|
||||
|
||||
> **Status (2026-05):** v0.1.0 shipped 2026-05-03 (LGPL-3.0-or-later). v0.1.1 ready to tag — closes the four shakedown follow-ups. Planning is version-based — see §13.
|
||||
> **Status (2026-05):** v0.1.0 + v0.1.1 + v0.1.2 shipped 2026-05-03 (LGPL-3.0-or-later). Planning is version-based — see §13.
|
||||
>
|
||||
> **Where else to look:**
|
||||
>
|
||||
@@ -531,18 +531,40 @@ Per-phase scope detail is preserved in `CHANGELOG.md` (per-version summary) and
|
||||
|
||||
First public preview. Linux AppImage. Full entry in `CHANGELOG.md`; binaries at <https://src.bundespruefstelle.ch/magdev/php-qml/releases/tag/v0.1.0>.
|
||||
|
||||
### v0.1.1 — bugfix release (ready to tag)
|
||||
### v0.1.1 — shipped 2026-05-03
|
||||
|
||||
All four shakedown follow-ups landed:
|
||||
Closed the four shakedown follow-ups identified during v0.1.0 shipping:
|
||||
|
||||
- **perfsmoke gap closed.** `HealthController` now constructor-injects `Publisher`; `/healthz` returns 200 only when `BridgeBundle` is fully container-resolvable. The `bundle` field in the response is the canary value perfsmoke + the bundled-mode integration test both check.
|
||||
- **Bundled-mode supervisor integration test.** `examples/todo/tests/bundled-supervisor.sh` (run via `make integration-bundled`) stages a fake AppImage layout in `/tmp` and exercises the whole supervisor codepath (`resolveFrankenphpBin` → `runMigrations` → `spawnChild` → cache/log redirect to user data dir) without needing a real `.AppImage` build. Wired into ci.yml. Catches every v0.1.0 shakedown bug.
|
||||
- **Skeleton AppImage parity.** `framework/skeleton/Makefile` gains `staging-symfony` + `appimage` targets mirroring the example's; `framework/skeleton/packaging/` ships starter `.desktop` + `.png`; `bin/php-qml-init` rewrites `BUNDLE_SRC` / `PACKAGING` Make variables and renames packaging files at scaffold time. `--vendor` mode also vendors `packaging/linux/` to `.bridge-packaging/`. Scaffolded apps inherit a working `make appimage` flow.
|
||||
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone.
|
||||
- **Cache-wipe on bundled launch** (added during v0.1.1 shakedown). Symfony bakes `kernel.project_dir` into its compiled cache; the AppImage's FUSE mount path changes per launch, so cache from launch N is stale by N+1. Supervisor now wipes `var/cache/` on every `initBundledMode`. Build-time cache warmup is the v0.2.0 follow-up.
|
||||
|
||||
### v0.1.2 — shipped 2026-05-03
|
||||
|
||||
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the cycle (clean child shutdown) with three non-breaking fixes from a post-v0.1.1 architecture audit:
|
||||
|
||||
- **Bundled supervisor: clean child shutdown.** The destructor's `teardownChild()` only ran during stack unwinding *after* `app.exec()` returned, by which point Qt's event loop was already mid-shutdown — so `QProcess::waitForFinished` couldn't reliably reap the child and Qt warned `QProcess: Destroyed while process is still running`, leaving an orphan frankenphp + its workers behind. Fix: connect `QCoreApplication::aboutToQuit` to `teardownChild` in the constructor, so the child is SIGTERM'd while the event loop is still active. Bundled-supervisor integration test gained a clean-shutdown assertion (no Qt warning, no orphan frankenphp under the host's PGID after SIGTERM).
|
||||
- **`bridge.qml_path` is now actually configurable.** `BridgeResourceMaker` and `BridgeWindowMaker` carried docstrings claiming the QML output dir was settable via the bundle's `qml_path` option, but the bundle never wired one — the constructor default was the only knob. `BridgeBundle::configure` now defines a `qml_path` node (default `../qml/`); `loadExtension` exposes it as the `bridge.qml_path` container parameter; `services.yaml` binds it into both makers. Apps configure with `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`. Default is unchanged so existing skeleton/example apps need no edit.
|
||||
- **`SessionAuthenticator` problem+json on entry-point path.** `onAuthenticationFailure` already returned RFC 7807 `application/problem+json` for *bad-token* requests, but Symfony's default entry point fired for *no-token* requests — yielding a Form-flavoured 302/401 instead. Implemented `AuthenticationEntryPointInterface::start`, factored the response into a `problemJson()` helper, so QML's RestClient sees one shape regardless of which path the firewall takes. Added test coverage.
|
||||
- **`CorrelationKeyListener::onTerminate` sub-request guard.** `onRequest` already had `isMainRequest()`, but `onTerminate` cleared unconditionally — so a sub-request finishing mid-controller would wipe the main request's correlation key, causing the matching Mercure echo to lose its `correlationKey` field and the optimistic UI to never reconcile. Defensive: real-world impact is low (FrankenPHP worker mode does not currently emit sub-requests), but cheap to fix and the obvious correctness bug.
|
||||
|
||||
### v0.2.0 — next minor
|
||||
|
||||
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
|
||||
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks **and** the public-API / DX items surfaced by the post-v0.1.2 audit. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
|
||||
|
||||
**Public-API surface (audit-driven, breaks pre-1.0 SemVer permitted):**
|
||||
|
||||
- **Ship interfaces for the bridge's three public services.** `Publisher`, `ModelPublisher`, and `CorrelationContext` are typehinted concretely everywhere (the Doctrine listener, the example `PingController`, every user controller that wants to fire a manual envelope) — the matching upstream Symfony idiom is `HubInterface` / `EventDispatcherInterface` / `NormalizerInterface`. Extract `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`; have the concrete classes implement them; switch every internal typehint over; document the interfaces as the public contract. Lets app code mock at the seam without a concrete-class spy and lets us iterate the implementations behind the contract.
|
||||
- **`BridgeOp` enum.** `'upsert'` / `'delete'` are passed as raw strings between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. PHP 8.1 backed enum is the obvious typed replacement; PLAN.md §4's envelope `op` field already enumerates `upsert` | `delete` | `replace` | `event` so the enum encodes a documented contract. Method signature change is API-visible — pre-1.0 SemVer permits it; ship deprecation paths if the audit surfaces external callers.
|
||||
- **`HealthController` deep-load canary refactor.** Constructor-injects `Publisher` only as a "is the bundle resolvable" probe (added in v0.1.1). Switching the dependency to a tiny `BridgeBundleInfo` value object that the bundle registers documents intent and decouples `/healthz` from the publisher contract — important once `PublisherInterface` lands.
|
||||
|
||||
**Maker DRY + DX (audit-driven):**
|
||||
|
||||
- **Maker shared helpers.** All three makers re-implement the same name-prompt-or-fail closure (`ucfirst(trim(…))` plus throw on empty) and re-spell their own camel-to-snake / camel-to-kebab regexes inline. Extract `Maker\Support\NameInput::askOrFail()` and `Maker\Support\Naming::camelTo($name, '_'|'-')` — single source of truth, three call sites.
|
||||
- **DTO-shaped controller scaffold (`make:bridge:resource --with-dto`).** Generated CRUD controllers currently accept any JSON shape: `if (isset($data['title'])) …` with silent type coercion, no required-field enforcement, malformed JSON swallowed as `?? []`. Add a `--with-dto` option that emits `Create<Name>Dto` + `Update<Name>Dto` DTOs alongside the controller and rewrites the action signatures to `#[MapRequestPayload] CreateTodoDto $dto`. Pulls `symfony/validator` into the skeleton/example dependencies; `#[Assert\NotBlank]` on title fields is the headline default. Symfony's payload-mapping infrastructure produces RFC 7807 problem+json on validation failure for free, fixing the field-mapping repetition between `create()` and `update()` at the same time. Once stable, flip `--with-dto` to default-on.
|
||||
- **Generated controller `findOr404` boilerplate.** `update()` and `delete()` both inline the find-or-404 problem+json response. Either factor a private helper into the template or migrate to Symfony's `#[MapEntity]` attribute (ships in 7.x).
|
||||
|
||||
**Makers + reactive types (Phase 3.x deferred):**
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -186,4 +190,38 @@ done
|
||||
echo "$HEALTHZ2_BODY" | grep -q '"status":"ok"' \
|
||||
|| fail "2nd-launch /healthz didn't return status:ok"
|
||||
|
||||
step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe)."
|
||||
# ── Clean shutdown: SIGTERM the host, assert no Qt warning + no orphan frankenphp ──
|
||||
step "graceful shutdown — assert the supervisor kills its frankenphp child"
|
||||
SHUTDOWN_PID="$PID"
|
||||
# Capture every descendant PID before killing, so we can verify they all exit.
|
||||
DESCENDANTS="$(pgrep -P "$SHUTDOWN_PID" || true)"
|
||||
kill -TERM "$SHUTDOWN_PID" 2>/dev/null || true
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||
kill -0 "$SHUTDOWN_PID" 2>/dev/null || break
|
||||
sleep 0.2
|
||||
done
|
||||
if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then
|
||||
kill -KILL "$SHUTDOWN_PID" 2>/dev/null || true
|
||||
fail "host didn't exit within 3s of SIGTERM"
|
||||
fi
|
||||
PID=""
|
||||
|
||||
# Qt warning means QProcess was destroyed before the child exited.
|
||||
if grep -q "QProcess: Destroyed while process .* is still running" "$LOG2"; then
|
||||
sed 's/^/ /' "$LOG2" >&2
|
||||
fail "host exited but logged QProcess-destroyed-while-running warning"
|
||||
fi
|
||||
|
||||
# Any descendant still alive = orphan; the supervisor's teardown didn't wait.
|
||||
for d in $DESCENDANTS; do
|
||||
if kill -0 "$d" 2>/dev/null; then
|
||||
# Be specific: only frankenphp orphans matter (QtNetwork might leave
|
||||
# short-lived helper threads but those exit on their own).
|
||||
if ps -p "$d" -o comm= 2>/dev/null | grep -q frankenphp; then
|
||||
kill -KILL "$d" 2>/dev/null || true
|
||||
fail "frankenphp child PID $d outlived the host (supervisor didn't clean up)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
step "All bundled-supervisor assertions passed (incl. 2nd-launch cache wipe + clean shutdown)."
|
||||
|
||||
@@ -11,3 +11,33 @@ services:
|
||||
PhpQml\Bridge\SessionAuthenticator:
|
||||
arguments:
|
||||
$expectedToken: '%env(default::BRIDGE_TOKEN)%'
|
||||
|
||||
# Maker classes extend symfony/maker-bundle's AbstractMaker, which is a
|
||||
# require-dev dependency. In `composer install --no-dev` builds (the
|
||||
# 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\BridgeResourceMaker:
|
||||
arguments:
|
||||
$qmlPath: '%bridge.qml_path%'
|
||||
|
||||
PhpQml\Bridge\Maker\BridgeWindowMaker:
|
||||
arguments:
|
||||
$qmlPath: '%bridge.qml_path%'
|
||||
|
||||
@@ -12,16 +12,23 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
final class BridgeBundle extends AbstractBundle
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array{qml_path?: string} $config
|
||||
*/
|
||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$builder->setParameter('bridge.qml_path', $config['qml_path']);
|
||||
$container->import(__DIR__.'/../config/services.yaml');
|
||||
}
|
||||
|
||||
public function configure(DefinitionConfigurator $definition): void
|
||||
{
|
||||
// Bundle config tree gains nodes when bridge:doctor and the
|
||||
// skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6).
|
||||
$definition->rootNode()
|
||||
->children()
|
||||
->scalarNode('qml_path')
|
||||
->info('Where make:bridge:resource and make:bridge:window write QML scaffolds. Path is resolved relative to the Symfony project dir.')
|
||||
->defaultValue('../qml/')
|
||||
->cannotBeEmpty()
|
||||
->end()
|
||||
->end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ final class CorrelationKeyListener implements EventSubscriberInterface
|
||||
|
||||
public function onTerminate(TerminateEvent $event): void
|
||||
{
|
||||
// Sub-requests share the kernel's correlation context with the main
|
||||
// request — clearing on a sub-request's TerminateEvent would wipe the
|
||||
// key while the main controller is still running.
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
$this->context->clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ use Symfony\Component\Uid\Uuid;
|
||||
*
|
||||
* The Doctrine subscriber installed by the bundle picks the entity up
|
||||
* automatically — no per-resource listener is generated. The QML snippet
|
||||
* goes to `qml_path` (default: `../qml/`, configurable via the bundle's
|
||||
* `qml_path` option in services.yaml).
|
||||
* goes to `qml_path` (default: `../qml/`, set via `config/packages/bridge.yaml`:
|
||||
* `bridge: { qml_path: ../qml/ }`).
|
||||
*
|
||||
* See PLAN.md §8 (*Custom makers*).
|
||||
*/
|
||||
|
||||
@@ -21,7 +21,8 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
* the first window and as many extra instances as it wants for the
|
||||
* multi-window test from PLAN.md §9 / §13 Phase 3.
|
||||
*
|
||||
* Generated file goes to `qml_path` (default: `../qml/`).
|
||||
* Generated file goes to `qml_path` (default: `../qml/`, set via
|
||||
* `config/packages/bridge.yaml`: `bridge: { qml_path: ../qml/ }`).
|
||||
*/
|
||||
final class BridgeWindowMaker extends AbstractMaker
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
|
||||
|
||||
/**
|
||||
* Validates the per-session bearer token shared between the Qt host
|
||||
@@ -22,7 +23,7 @@ use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPasspor
|
||||
* Qt host generates it per session and passes it to FrankenPHP via env.
|
||||
* See PLAN.md §3 (*Run modes*, *Edge cases — Per-session secret rotation*).
|
||||
*/
|
||||
final class SessionAuthenticator extends AbstractAuthenticator
|
||||
final class SessionAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
@@ -57,13 +58,30 @@ final class SessionAuthenticator extends AbstractAuthenticator
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||
{
|
||||
return $this->problemJson($exception->getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point invoked when access is denied without a triggered authenticator
|
||||
* (e.g. an anonymous request to a protected route). Without this, Symfony
|
||||
* returns its default `WWW-Authenticate: Form` 302/401, which clients
|
||||
* speaking JSON would never expect — same shape as onAuthenticationFailure
|
||||
* keeps QML's RestClient error mapping consistent across both paths.
|
||||
*/
|
||||
public function start(Request $request, ?AuthenticationException $authException = null): Response
|
||||
{
|
||||
return $this->problemJson($authException?->getMessage() ?? 'Bearer token required.');
|
||||
}
|
||||
|
||||
private function problemJson(string $detail): JsonResponse
|
||||
{
|
||||
return new JsonResponse(
|
||||
[
|
||||
'type' => 'about:blank',
|
||||
'title' => 'Unauthorized',
|
||||
'status' => Response::HTTP_UNAUTHORIZED,
|
||||
'detail' => $exception->getMessage(),
|
||||
'detail' => $detail,
|
||||
],
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
['Content-Type' => 'application/problem+json'],
|
||||
|
||||
@@ -82,4 +82,20 @@ final class SessionAuthenticatorTest extends TestCase
|
||||
self::assertSame(401, $body['status']);
|
||||
self::assertSame('Unauthorized', $body['title']);
|
||||
}
|
||||
|
||||
public function testStartReturnsProblemJsonForAnonymousAccess(): void
|
||||
{
|
||||
// Entry-point path: no Authorization header → supports() returns false →
|
||||
// Symfony invokes start() with no exception. Without our start(), the
|
||||
// default would be a Form-flavoured 302/401 — wrong shape for QML.
|
||||
$auth = new SessionAuthenticator('s3cret');
|
||||
$response = $auth->start(new Request());
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
self::assertSame('application/problem+json', $response->headers->get('Content-Type'));
|
||||
$body = json_decode((string) $response->getContent(), true);
|
||||
self::assertSame(401, $body['status']);
|
||||
self::assertSame('Unauthorized', $body['title']);
|
||||
self::assertSame('Bearer token required.', $body['detail']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,12 +11,15 @@
|
||||
#include <QProcessEnvironment>
|
||||
#include <QQmlEngine>
|
||||
#include <QRandomGenerator>
|
||||
#include <QSocketNotifier>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
#include <csignal>
|
||||
#include <fcntl.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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<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
|
||||
|
||||
BackendConnection::BackendConnection(QObject* parent)
|
||||
@@ -42,6 +92,22 @@ BackendConnection::BackendConnection(QObject* parent)
|
||||
m_retryTimer->setInterval(kProbeIntervalMs);
|
||||
connect(m_retryTimer, &QTimer::timeout, this, &BackendConnection::probe);
|
||||
|
||||
// aboutToQuit fires while the event loop is still active, before main()
|
||||
// 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"));
|
||||
if (!explicitUrl.isEmpty()) {
|
||||
m_mode = Mode::Dev;
|
||||
@@ -314,6 +380,14 @@ bool BackendConnection::spawnChild(QString* errorOut)
|
||||
void BackendConnection::teardownChild()
|
||||
{
|
||||
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) {
|
||||
m_child->terminate();
|
||||
if (!m_child->waitForFinished(2000)) {
|
||||
@@ -321,7 +395,6 @@ void BackendConnection::teardownChild()
|
||||
m_child->waitForFinished(1000);
|
||||
}
|
||||
}
|
||||
disconnect(m_child, nullptr, this, nullptr);
|
||||
m_child->deleteLater();
|
||||
m_child = nullptr;
|
||||
m_childLogBuffer.clear();
|
||||
|
||||
Reference in New Issue
Block a user