Skip to content

Name the compiled container after the version that compiled it - #845

Merged
blaipr merged 1 commit into
mainfrom
fix/a-compiled-container-belongs-to-its-version
Aug 23, 2026
Merged

blaipr merged 1 commit into
mainfrom
fix/a-compiled-container-belongs-to-its-version

Conversation

@blaipr

@blaipr blaipr commented Aug 23, 2026

Copy link
Copy Markdown
Member

The defect

php-di writes the compiled container once and reuses whatever file it finds under that class name,
without revalidating the definitions behind it. The name is therefore the only invalidation
there is — and it carried the module but not the version.

var/cache is runtime state that survives a deployment, and no upgrade step clears it. So upgrading
sysPass in place leaves the previous release's compiled container in front of the new code, and
any constructor signature or DI definition that changed between the two fatals with a TypeError —
on every request, web and API alike.

Nothing recovers from it through the UI either: the container is built in Base.php, before Init
runs, so the upgrade page that would fix things cannot be reached.

This is not hypothetical for this codebase — constructor signatures changed three times in recent
work alone (#830 Adapter, #834 Init, #839 AuthTokenBase). I hit it by accident: a live API
request returned

SP\Domain\Category\Adapters\Category::__construct(): Argument #3 ($acl) must be of type
SP\Domain\Core\Acl\AclInterface, SP\Application\CustomField\Services\CustomField given

from a compiled container predating #830.

The comment already above this code had the right insight — it explains that a shared name let
whichever entry point compiled first decide the others' bindings — and stopped one timescale short:
the same reuse applies across releases, not just across modules.

The fix

compiledContainerName($module, $version) builds the name from both. New code compiles a new file;
the old one is left in place rather than deleted, since a request arriving mid-deployment may still
be using it.

Demonstrated, not just tested

With a previous release's container left at the old fixed name:

old naming: HTTP 200, body = "stale container from the previous release"   <- loaded and fatal
new naming: HTTP 200, login page                                          <- ignored

Both entry points were then re-checked from a cleared cache — web 200, API 401 for an
unauthenticated call — and the files written are now CompiledContainerWeb40021031301.php and
CompiledContainerApi40021031301.php.

Test

Three in FunctionsTest: two versions do not share a name, two modules do not share a name, and the
result is a valid PHP class name — the normalised version is dotted (400.21031301), and a dot in a
class name does not compile.

Base.php itself stays untested, as CLAUDE.md records for bootstrap code; the naming rule was
extracted precisely so the part that carries the fix is testable.

OK (4043 tests, 36987 assertions)   unit
OK (1006 tests, 3045 assertions)    integration

PHPStan level 6 and PHPCS clean.

Also

CLAUDE.md gains this under the DI section, including the practical half: a stale compiled
container is the first thing to suspect when a local instance fatals on a constructor signature you
have just changed.
It is not a bug in the change — clear var/cache. I nearly reported one as a
production defect on exactly that basis.

php-di writes the compiled container once and reuses whatever file it finds under
that class name, without revalidating the definitions behind it, so the name is
the only invalidation there is — and it carried the module but not the version.

var/cache is runtime state that survives a deployment and no upgrade step clears
it, so upgrading in place left the previous release's compiled container in front
of the new code. Any constructor signature or definition that changed between the
two then fatals with a TypeError on every request, web and API alike, and it
cannot be recovered from through the UI: the container is built in Base.php,
before Init runs, so the upgrade page is unreachable.

Constructor signatures changed three times in recent work alone, and this turned
up by accident — a live API request answering with a TypeError about an argument
of a class that no longer takes it, from a container compiled before that change.

The comment already above this code had the insight and stopped one timescale
short: it explains that a shared name let whichever entry point compiled first
decide the others' bindings, which is the same reuse, across modules rather than
across releases.

compiledContainerName() now builds the name from both. The previous release's
file is left where it is rather than deleted, since a request arriving
mid-deployment may still be using it.

Demonstrated with a previous release's container left at the old fixed name: the
old naming loads it and the page dies, the new naming ignores it. Both entry
points were then re-checked from a cleared cache, answering 200 and 401.

Three tests cover the naming rule — per version, per module, and a valid class
name, since the normalised version is dotted and a dot does not compile. Base.php
itself stays untested as bootstrap code; the rule was extracted so the part that
carries the fix is testable.
@blaipr
blaipr merged commit 11e9d6b into main Aug 23, 2026
8 checks passed
@blaipr
blaipr deleted the fix/a-compiled-container-belongs-to-its-version branch August 23, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant