Skip to content

18 August 2026

7 changes:

  • The last twelve were choices
  • A linter for the one asset that ships
  • A JSON reply that did not stop
  • A placeholder that ate the query string
  • Every page answered 404 to HEAD
  • A supervisor that could not tell a corpse from a worker
  • A pid answers a question about your own process table

The last twelve were choices

Application::getInstance() is a factory: with no instance for the key it reads app.php, defines constants and runs the whole constructor — database, language, session. currentInstance() is the lookup.

Nine call sites in the authentication, identity and database layers were converted yesterday, with a structural guard over those four directories. The changelog entry for that said twenty-seven calls remained outside the guard and were not audited one by one. They have been now.

Fixed

Eleven converted, one deleted:

Where Why it was a lookup all along
DevPanelController (×2), DocumentTypes\Html, DocumentTypes\Raw a CSP-nonce read during rendering, which happens inside a request. Three of the four already had if ($app && …) — a guard for a null the factory cannot return
Broadcasting\Broadcastable building an application to ask whether broadcasting is configured, inside a try whose catch reports "not configured"
Console\Commands\RouteList its third fallback strategy asks whether a global instance exists; constructing one to discover it has no router is the opposite of a fallback
Testing\TestClient its if ($appInstance === null) branch was unreachable and carried a coverage-ignore explaining that. It is live now
Init — five generated templates the nav features read, three footers and a page title, shipped into every new project. Now currentInstance()?->…, and the footers gained the escaping they were missing
Addon\System\Session deleted. The line assigned $app and nothing ever read it, so a session-cleanup addon constructed an entire application for an unused variable

A docblock in NavRegistry showed getInstance()->applicationInfo['features'] as the way to read features inside a theme header. Documentation that teaches the shape a guard forbids is worse than no example.

Kept, with the reason

Twelve, in two groups.

Console bootstrapsConsole\Application, TimescaleDrain, TimescaleEnsure, PolicyEngine, BroadcastServe, BaseTestCase, and the two bootstrap scripts the scaffolder generates. Building an application is what they are for.

Constructor fallbacks in Controller and Theme__construct($application = null) resolving the current one when none was passed. In a real request both calls answer identically, and currentInstance() would put null into $this->application for the standalone case, which every unit test that builds a controller by hand relies on not happening. Every controller and every theme, for no gain in production. That is a worse trade than the one it would fix.

The guard covers the directories where the factory is a hazard rather than a choice. These twelve are choices, and they are now written down as such rather than remaining an unexamined number in a changelog entry.

A linter for the one asset that ships

debugbar.js is around 3700 lines and is served on every page of every project that enables the debug toolbar. It had var hasMvcPage declared twice.

A consuming project's linter found it, and the duplicate had stopped 1,195 panel tests from running there. Nothing in this repository could have caught it: there was no package.json, no ESLint config, and no CI workflow for JavaScript at all — only the docs deploy.

Added

./lintjs              # ESLint over src and tests/js
./lintjs --fix
./lintjs src          # a subtree

Inside the container, like ./dockertest, and for the same reason: the container is the environment. A linter that reports differently depending on whose Node ran it is worse than no linter. npm joins nodejs in the Dockerfile, and ./lintjs installs it into an image built before that rather than failing on a detail nobody wants to think about while linting.

A JavaScript workflow runs the linter and node --test on Node 20 — the version the container ships, so a CI failure reproduces locally instead of being a CI-only surprise.

Every rule is a defect, not a preference

No quote policy, no semicolons, no indentation. debugbar.js predates this config by years: reformatting it would bury the next real change in noise, and a --fix sweep across 3700 lines is exactly the diff nobody can review.

What is enabled is what a parser can decide and a test cannot — no-redeclare, no-undef, no-dupe-keys, no-unreachable, valid-typeof, use-isnan and a dozen more of that kind.

A unit test for this was tried, and deleted

A test scanning for duplicate var declarations flagged var rows in six unrelated functions, because it matched an identifier rather than a redeclaration. The reporter of the original bug predicted exactly that. no-redeclare understands scope; a grep never will.

Verified by putting the bug back: two var hasMvcPage declarations produce 'hasMvcPage' is already defined no-redeclare, and the suite stays green either way — which is the point. The linter sees what the tests cannot.

The first run found six things, and two were mine

Blob and setImmediate were missing from the globals I had configured. Cheap to fix and worth recording: the first thing a new linter reports is often its own configuration.

The other four were real:

  • A dead CLIENT_TABS lookup in debugbar.js. Deleting it correctly meant reading the code rather than the error: the three tabs it named — errors, client, apiare special, because they are drawn from what the script observed rather than from a response payload. But that is encoded as explicit tab.key === … checks in three separate places, and nothing read the table. It duplicated knowledge that lives elsewhere. Wiring it up would mean editing three behavioural branches in a 3700-line asset, which is a refactor and not the addition of a linter, so the observation is recorded in the file where the constant stood.
  • Three tests destructuring a sandbox they never used. The same line appears 30 times in that file and only three of them were unused, so a blanket replacement would have broken the other 27 — the three were edited by line number.

One process note

The first attempt at all of this ran npm install on the host, which has Node 24 while the container has Node 20. That is the wrong environment for the same reason ./dockertest exists, and the host artefacts were removed before anything was committed. Both new commands run in the container only.

A JSON reply that did not stop

Reported from a project consuming the framework: /devpanel/logs?request=… returned its JSON, and then this, in the same response body:

{"request":"c6264dcd8e596cac","count":0,"lines":[]}
Deprecated: stripos(): Passing null to parameter #1 ($haystack) …
Fatal error: Uncaught TypeError: Application::renderThroughTheme(): Argument #1 ($content)
must be of type string, null given

The toolbar showed "The server did not answer — try again". The server had answered perfectly well and then kept talking.

What was wrong

DevPanelController writes its own responses. renderLayout() echoes the panel and calls terminate(); renderError() echoes the error page and calls terminate(). sendJson() echoed the JSON and returned null — the same contract with the ending left off, and the only outlier in the file.

A null return tells a dispatcher that the action produced nothing. So the application carried on and rendered a page on top of a response that was already complete.

The rest of the failure is worth writing down, because it is a good example of two reasonable decisions meeting badly:

  • the application's $output property is magicBase::__get() answers null for anything never assigned;
  • its guard for "did a controller produce output?" is if ($this->output !== '').

null !== '' is true. So the guard passed holding a null, stripos() was called on it twice, and renderThroughTheme() fatalled on a string parameter. Its dispatcher was careful — it handles Response, a string, and any object with send() — and null fell through every branch.

The fix, and the fix that was rejected

sendJson() calls terminate() after writing. One line, and it makes the outlier consistent with the two paths beside it.

Returning a Response was the other candidate and reads better in the abstract: it is the framework's own "I am the whole response" object, and both Application::exec() and that application's router handle it. It was rejected on a detail that matters more than the shape — that application routes a non-API, non-HTML body through its theme, so a JSON body returned this way would have come back wrapped in a page. A JSON reply to an XHR is finished when it has been written.

The test that would have caught it

Not one asserting the JSON. The JSON was correct; every existing test that looked at this endpoint passed, before and after.

$this->assertSame(1, $this->controller->terminated);

The testable subclass already stubbed terminate() as a no-op so the suite would not exit; it now counts instead, and two tests assert that both the success and the 400 path declare the request finished. Verified by removing the terminate() again: both redden.

That is the shape to remember — when a bug is "the right output, followed by more", the assertion has to be about the ending, not the content.

A placeholder that ate the query string

Reported from a project whose station pages answered 404 to every link shared on Facebook. Facebook appends fbclid to the URL it posts, and the page it pointed at stopped existing:

GET /station/athens            → 200
GET /station/athens?fbclid=x   → 404
GET /stations?playable=1       → 200      ← the static route was fine

The pattern is what made it survive: only routes with a placeholder were affected, and only when a query string was present. Nothing in the application had changed.

What was wrong

Request::getRequestUri() returns the request with its query string still attached, and Route::matches() used to try the compiled pattern against that string first, stripping the query and retrying only if nothing had matched.

For a static route the retry did the work: /stations?playable=1 misses every pattern, falls through, and matches on the second attempt.

For station/{slug} there was no second attempt, because the first one succeeded on the wrong string. A placeholder compiles to [^/]+ by default and a query string contains no /, so the pattern matched /station/athens?fbclid=x happily and filled parameters['slug'] with athens?fbclid=x. The route matched; the controller then looked up a slug nobody has.

The retry block was therefore unreachable for exactly the routes that needed it.

The fix

The strip moved from after the regex to before the first comparison, in Pramnos\Routing\Route::matches(). A route that declares its own query string is left alone — that guard was already there and is why this is a conditional strip rather than an unconditional parse_url().

Router::getMatchedRoute() gained the same lookup on the path alone, so a static route with a query string hits the O(1) map instead of scanning the whole table before matches() sorted it out. Correct either way; this only makes the fast path reachable.

Fixed

  • Route::matches() no longer captures the query string into a route placeholder. Any route ending in {param} was affected: tracking parameters (fbclid, utm_*), a ?page=2 on a parameterised listing, and a redirect carrying ?error=… back to the page that produced it.
  • Router::getMatchedRoute() matches static routes with a query string on the fast path.

tests/Unit/Routing/RouteIgnoresQueryStringTest.php covers both directions, including a parameter whose value contains slashes (?return=/station/other) and the guard that keeps a route registered with its own query string reachable.

Every page answered 404 to HEAD

Reported from an application whose sitemap had just started working — 2,250 server-rendered pages announced to crawlers, and then this:

GET  /station/athens  → 200
HEAD /station/athens  → 404
GET  /sitemap.xml     → 200
HEAD /sitemap.xml     → 404

Every page. Every application on this router.

What was wrong

RFC 9110 §9.3.2 is not ambiguous: "The HEAD method is identical to GET except that the server MUST NOT send content in the response." A resource that answers GET answers HEAD.

Routes are stored per method, so $this->routes['HEAD'] held only the routes an application had declared for HEAD explicitly — which is, in practice, none. getMatchedRoute() looked in that table, found nothing, and returned null. The application then answered 404 for a page it serves perfectly well.

It is not a curiosity about an unusual verb. HEAD is what link checkers, uptime monitors, curl -I, several crawlers and every "is this URL alive" tool send first — so a site could be entirely reachable and report as entirely broken, with nothing in its own logs looking wrong.

The fix

getMatchedRoute() retries a HEAD request against the GET table when nothing in the HEAD table matched. Tried second, so an application that declares a cheaper HEAD than its GET — an existence check that skips the expensive query — keeps it.

The three lookups (exact, query-stripped, pattern) moved into a private matchWithin() so the retry does not duplicate them, and Route::matches() gained an optional second parameter for matching as a method other than the request's own. Both additive; no existing signature changed.

Only HEAD falls back. POST answered by a GET route would run a read handler for a write request, and make a route look like it accepts submissions.

The body is not the router's business. PHP's SAPI drops the content of a HEAD response, and an application writing its own output can read the method. What is fixed here is which route runs.

Fixed

  • Router::getMatchedRoute() answers a HEAD request from the GET table when no HEAD route matches, with parameters filled exactly as GET would fill them.

Added

  • Route::matches($request, $asMethod = null) — match as a given method rather than the request's own. Optional and additive.

tests/Unit/Routing/HeadIsAnsweredByGetTest.php covers both directions: the fallback, the parameters, an explicit HEAD route winning, a URI nobody serves still refusing, POST and DELETE not borrowing the GET table, and GET itself unchanged.

A supervisor that could not tell a corpse from a worker

Reported from a development stack where three of four background daemons had been dead for fourteen hours. daemons:start was running, its log had no errors in it, and the dashboard listed every worker as present:

PID 1    php myapp.php daemons:start
PID 14   [php] <defunct>
PID 16   [php] <defunct>
PID 18   [php] <defunct>
PID 20   php myapp.php realtime:serve

The features behind those three workers — now-playing, airplay statistics, feed-health tiering — were simply empty, and nothing anywhere said why.

What was wrong

isProcessRunning() asked posix_kill($pid, 0). That call answers "may I signal this process", and a process which has exited but has not been reaped still says yes: its PID stays in the table until somebody waits on it. So the supervisor asked whether its own dead child was alive and was told it was fine.

The state that produces those zombies is not an edge case, it is what a container does. Workers are started detached:

$shell = 'nohup setsid ' . $command . ' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';

The intermediate shell exits immediately, so the worker is orphaned, and an orphan is reparented to PID 1 — which inside a container is the orchestrator itself. It therefore becomes the parent of every daemon it starts, reaps none of them (there is no wait loop, and pcntl is frequently not built into a PHP image), and every graceful stop leaves behind a <defunct> entry it reads as a healthy worker.

A redeploy is what triggered it. The git-hash check asks all daemons to stop; each one exits; each becomes a zombie; and from then on the reconcile loop sees four PIDs that "exist" and starts nothing.

The fix

/proc/<pid>/stat carries the process state, and it is now read before anything else:

$state = $this->processState($pid);

if ($state !== null) {
    return $state !== 'Z' && $state !== 'X';
}

if (function_exists('posix_kill')) {
    return @posix_kill($pid, 0);
}

null means this platform cannot answer and is deliberately distinct from 'Z': on a system with no /proc the previous posix_kill() behaviour stands, unchanged.

The parsing is half of it. /proc/<pid>/stat is pid (comm) state … with comm unescaped — and a zombie's comm is (php) <defunct>. Splitting the line on whitespace puts <defunct> where the state belongs, so the state is taken from after the last ).

reapExitedChildren() now runs at the top of each supervisor cycle, before reconciling, so a worker that has just exited is out of the table by the time the loop asks about it. Where pcntl_waitpid is unavailable it does nothing: the zombies remain visible in ps but no longer convince the supervisor, which is the part that mattered.

Why this is worth a release note

A supervisor that reports success while its workers are dead is worse than no supervisor. The application had been running its whole background stack in development specifically so that a bug in one of those queries could not stay invisible until production — and the mechanism meant to guarantee that had been quietly off for the whole day.

If you run daemons:start in a container, build pcntl into the image. Without it the orchestrator can neither re-exec itself on a redeploy (so a newly-declared worker never appears) nor reap what it inherits.

Tests

isProcessRunning() is asserted against a real zombie rather than a stubbed /proc: a child is started with proc_open, allowed to exit, and deliberately not reaped. The test also asserts the premise it depends on — that posix_kill($pid, 0) still accepts that PID — so if the kernel ever stopped answering that way, the test would say so rather than passing for a new reason.

A pid answers a question about your own process table

Reported from an application whose admin panel showed all four background daemons as down while all four were running, and whose /api/realtime-config therefore advertised the SSE fallback with a healthy WebSocket worker listening and accepting connections.

panel:      stats down · maintenance down · tracker down · realtime down
the host:   all four running, locks touched seconds ago

What was wrong

DaemonOrchestrator::status() decided each daemon's running flag like this:

'running' => $daemonPid > 0 && $this->isProcessRunning($daemonPid),

Which is correct, and answers a question nobody asked. status() is read by whatever asks — a web request, an admin panel, a health endpoint — and what asks is frequently not the process that started the daemons. In the reported case the supervisor ran in one container and the panel was served from another, where pid 20 is either nothing or an unrelated process.

The same fault is reachable on a single host: any reader that is not the supervisor is looking at a pid it did not record, and a recycled pid is a false yes exactly as a foreign namespace is a false no.

The fix

A daemon is judged by its heartbeat when its pid cannot answer:

protected function daemonLooksAlive(int $pid, string $lockFile): bool
{
    if ($pid > 0 && $this->isProcessRunning($pid)) {
        return true;
    }

    if ($lockFile === '' || !is_file($lockFile) || is_file($lockFile . '.stop')) {
        return false;
    }

    $age = time() - (int) @filemtime($lockFile);

    return $age >= 0 && $age <= static::HEARTBEAT_STALE_SECONDS;
}

Every managed worker touches its lock file on each heartbeat, and the lock lives where both sides can read it, so "touched within the stale window" is a fact about the daemon rather than about the reader. A .stop sentinel beside it still means asked to go, whatever the timestamp says.

The pid is still consulted and still answers first, so a single-host install with a daemon that declares no lock file behaves exactly as before.

Why this is the second entry today

The other one — a zombie is not a running daemon — is the same sentence from the other side. posix_kill($pid, 0) says yes to a corpse; a corpse touches nothing. Between them the rule is: a process table tells you about processes, and a heartbeat tells you about work. A supervisor wants the second.

Tests

daemonLooksAlive() is asserted against a real lock file across three states — fresh with a foreign pid, aged past the stale window, and with a .stop sentinel beside it — and against a live pid with no lock at all, which is the path that must not change.