6 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
magdevandClaude Opus 4.7 8b2fc4dd06 release prep v0.1.2: collapse audit fixes into v0.1.2
CI / Quality (push) Failing after 5m8s
Release / Linux AppImage (push) Failing after 5m4s
The previous commit (0cceefc) staged the audit-driven fixes under a
hypothetical v0.1.3 since v0.1.2 hadn't tagged yet. Tagging cadence
decision: ship them all as v0.1.2 — there's no point spending a tag
on the clean-shutdown fix alone when v0.1.3 was already lined up
behind it. PLAN.md §13 + CHANGELOG.md collapse v0.1.2 + v0.1.3 into
the single v0.1.2 entry, dated 2026-05-03; orphaned [0.1.3] link
reference at the foot of CHANGELOG removed.

No code changes — the four fixes from 0cceefc + f132c3c stand as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:26 +02:00
magdevandClaude Opus 4.7 0cceefc890 v0.1.3: audit-driven non-breaking fixes
Three bugs surfaced by the post-v0.1.2 architecture audit:

- bridge.qml_path is now actually configurable. BridgeBundle::configure
  defines the qml_path scalar node (default ../qml/); loadExtension
  exposes it as the bridge.qml_path container parameter; services.yaml
  binds it into BridgeResourceMaker + BridgeWindowMaker. Apps override
  with `config/packages/bridge.yaml`. The existing maker docstrings
  claimed this worked already — they lied; now they don't.

- SessionAuthenticator implements AuthenticationEntryPointInterface and
  routes the no-token entry-point path through the same problem+json
  helper as onAuthenticationFailure, so QML's RestClient sees one error
  shape regardless of which firewall path was taken. Test added.

- CorrelationKeyListener::onTerminate guards on isMainRequest() now,
  matching onRequest's existing guard. No user-visible impact in
  worker mode (no sub-requests emitted), but the asymmetry was a
  defensive bug that would corrupt optimistic-update reconciliation.

PLAN.md §13 gains a v0.1.3 section + folds the audit's API-surface
items (PublisherInterface / ModelPublisherInterface / BridgeOp enum /
maker DRY / DTO-shaped scaffold) into v0.2.0. CHANGELOG.md mirrors.

PHPStan + cs-fixer + PHPUnit (17/17) + maker snapshot tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:54 +02:00
11 changed files with 182 additions and 14 deletions

No files matched your search

+6 -1
View File
@@ -10,11 +10,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- (none yet — next changes land here) - (none yet — next changes land here)
## [0.1.2] — TBD ## [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 ### 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). - **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 ## [0.1.1] — 2026-05-03
+20 -3
View File
@@ -1,6 +1,6 @@
# php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework # php-qml — Plan for a Symfony/FrankenPHP + Qt/QML Desktop Framework
> **Status (2026-05):** v0.1.0 + v0.1.1 shipped 2026-05-03 (LGPL-3.0-or-later). v0.1.2 in progress on `dev`. 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:** > **Where else to look:**
> >
@@ -541,13 +541,30 @@ Closed the four shakedown follow-ups identified during v0.1.0 shipping:
- **Caddyfile fmt.** `framework/skeleton/Caddyfile` and `examples/todo/Caddyfile` reformatted per `caddy fmt`; the "Caddyfile input is not formatted" boot warning is gone. - **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. - **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 — in progress on `dev` ### 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). - **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 ### 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):** **Makers + reactive types (Phase 3.x deferred):**
+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
+30
View File
@@ -11,3 +11,33 @@ services:
PhpQml\Bridge\SessionAuthenticator: PhpQml\Bridge\SessionAuthenticator:
arguments: arguments:
$expectedToken: '%env(default::BRIDGE_TOKEN)%' $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%'
+10 -3
View File
@@ -12,16 +12,23 @@ use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
final class BridgeBundle extends 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 public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
{ {
$builder->setParameter('bridge.qml_path', $config['qml_path']);
$container->import(__DIR__.'/../config/services.yaml'); $container->import(__DIR__.'/../config/services.yaml');
} }
public function configure(DefinitionConfigurator $definition): void public function configure(DefinitionConfigurator $definition): void
{ {
// Bundle config tree gains nodes when bridge:doctor and the $definition->rootNode()
// skeleton's wiring need settable knobs (Phase 1 sub-commits 3 & 6). ->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 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(); $this->context->clear();
} }
} }
@@ -33,8 +33,8 @@ use Symfony\Component\Uid\Uuid;
* *
* The Doctrine subscriber installed by the bundle picks the entity up * The Doctrine subscriber installed by the bundle picks the entity up
* automatically — no per-resource listener is generated. The QML snippet * automatically — no per-resource listener is generated. The QML snippet
* goes to `qml_path` (default: `../qml/`, configurable via the bundle's * goes to `qml_path` (default: `../qml/`, set via `config/packages/bridge.yaml`:
* `qml_path` option in services.yaml). * `bridge: { qml_path: ../qml/ }`).
* *
* See PLAN.md §8 (*Custom makers*). * 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 * the first window and as many extra instances as it wants for the
* multi-window test from PLAN.md §9 / §13 Phase 3. * 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 final class BridgeWindowMaker extends AbstractMaker
{ {
+20 -2
View File
@@ -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\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport; use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; 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 * 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. * 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*). * 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( public function __construct(
#[\SensitiveParameter] #[\SensitiveParameter]
@@ -57,13 +58,30 @@ final class SessionAuthenticator extends AbstractAuthenticator
} }
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response 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( return new JsonResponse(
[ [
'type' => 'about:blank', 'type' => 'about:blank',
'title' => 'Unauthorized', 'title' => 'Unauthorized',
'status' => Response::HTTP_UNAUTHORIZED, 'status' => Response::HTTP_UNAUTHORIZED,
'detail' => $exception->getMessage(), 'detail' => $detail,
], ],
Response::HTTP_UNAUTHORIZED, Response::HTTP_UNAUTHORIZED,
['Content-Type' => 'application/problem+json'], ['Content-Type' => 'application/problem+json'],
@@ -82,4 +82,20 @@ final class SessionAuthenticatorTest extends TestCase
self::assertSame(401, $body['status']); self::assertSame(401, $body['status']);
self::assertSame('Unauthorized', $body['title']); 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']);
}
} }
+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();