Skip to content

fix(session): satisfy the PHP 8.6 session handler contract and align gc() with the interface - #15

Merged
tyrsson merged 8 commits into
1.0.xfrom
fix/session-handler-86-forward-compat
Oct 21, 2026
Merged

tyrsson merged 8 commits into
1.0.xfrom
fix/session-handler-86-forward-compat

Conversation

@tyrsson

@tyrsson tyrsson commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Seven commits: DbSessionHandler contract work, plus local database test parity with CI.

Seven findings from #2's list are addressed; the remaining ones are documented below and deliberately left.

PHP 8.6 session handler contract

PHP 8.6 emits two E_WARNINGs at class declaration when a SessionHandlerInterface implementation omits create_sid() and validateId(), and phpunit's failOnWarning turns them into a red suite even in tests that never open a session.

Implements SessionIdInterface and SessionUpdateTimestampHandlerInterface and adds the three methods. updateTimestamp() delegates to write() because under 8.6 strict mode an unchanged session closes through it instead of write().

Also correct from PHP 9.0 onward, where SessionHandlerInterface declares both methods itself.

gc() parameter name

SessionHandlerInterface::gc() declares $max_lifetime. This implementation called it $maxLifetime, which mago reports as incompatible-parameter-name:

Parameter #1 of PhpDb\Session\DbSessionHandler::gc() is named $maxLifetime
but parent SessionHandlerInterface::gc() names it $max_lifetime

Named arguments make parameter names part of the callable contract, so $handler->gc(max_lifetime: 60) fails against this class. PHP's session module calls positionally, which is why it has been latent rather than broken.

The rename requires a lint suppression, since max_lifetime is snake case and variable-name wants camel case, but the interface fixes the spelling. mago's own suggestion would rename it back and restore the defect. The suppression follows the pattern already used for create_sid on this branch.

Local database parity with CI

The suite could not run locally as it runs in CI. Three separate asymmetries, fixed together so no engine is a one-off:

  • One credential pair per engine. phpunit.xml.dist used root with no password for mysql, while pgsql and sqlite each had their own dialect of the same idea. All three now use database webware, user webware, password webware, on 127.0.0.1 with their usual ports. That is the same shape a contributor, the docker services and the CI container all use.
  • sqlite path resolution. Fixture loader and adapter each resolved the file path themselves. A new SqliteDsn helper expands a :TMP: token in the DSN so the two cannot disagree about which file is under test.
  • pgsql hostname. Resolved on 127.0.0.1 like mysql rather than through a service name that only exists inside a container network.

CI had to follow: webware-ci.json now passes MYSQL_USER and MYSQL_PASSWORD to the database container, so the webware user exists there too. Without that the legs fail with Access denied for user 'webware'@'172.17.0.1'. Verified against a real mysql:9.7 container that the user is created, can reach the database, and can run DDL.

pgsql fixture loader

The loader now ensures the database exists rather than dropping and recreating it, mirroring MysqlFixtureLoader.

This is forced by engine behaviour, not preference. MySQL permits DROP DATABASE while connections are open; PostgreSQL does not. Dropping needs WITH (FORCE), which needs pg_signal_backend, and creating needs CREATEDB, so the test role would have to be effectively superuser. There is no CREATE DATABASE IF NOT EXISTS in Postgres either, so existence is read from pg_database first and the database is created only when missing. In CI the container already provisions it, making this a no-op there.

At teardown the suite's own connection is still open, and a non-superuser cannot terminate its own backend. dropDatabase() therefore tolerates only SQLSTATE 55006 and rethrows anything else. That is safe because the outcome the drop exists to achieve is already guaranteed: CI uses a disposable container per leg, and each test creates and drops its own table, so no state carries between runs.

An earlier commit on this branch (9e82c46) reset the public schema instead; b3efad7 replaced it.

Verification

  • mago format --check: clean
  • mago lint: clean
  • mago analyze: 8 issues, down from 9, the incompatible-parameter-name warning gone
  • mago guard: clean
  • composer test: 70 tests, 141 assertions, OK
  • composer test-integration: 30 tests, 73 assertions, OK, verified twice: once with the webware database already present, and once after dropping it so the create path is exercised
  • CI: six of six legs green as of f299fd7

Still open in webware-phpdb

These were isolated from a mago analyze run and are not addressed here:

Code Location Assessment
mixed-assignment src/Session/SessionPayload.php:37 unserialize() returns mixed
mixed-assignment src/Session/DbSessionHandler.php:96 query result is mixed
mixed-assignment src/Session/DbSessionHandler.php:97 derived from the above
mixed-assignment src/Session/PhpDbSessionPersistence.php:135 same pattern
redundant-comparison x2, redundant-condition x2 src/SchemaFactory.php:96, :143 mago false positives

The four SchemaFactory findings are wrong. mago resolves $schema::NAME through SchemaInterface::NAME = '' and ignores that implementing enums override it (test/unit/WebwareSchema.php:13 sets NAME = 'webware'). Verified at runtime that the expression yields 'webware', so the comparison is not redundant.

Worth knowing: the --potentially-unsafe fix for those would rewrite both lines to $enumSchema = null;, silently dropping the only read of $schema::NAME. Safe-level --fix applies nothing. They should be suppressed, not fixed.

Fix the phpunit schema pointer

phpunit.xml.dist pointed xsi:noNamespaceSchemaLocation at https://schema.phpunit.de/13.1/phpunit.xsd. Infection validates the config against that schema before running a single mutant, so a URL there makes the mutation job a live network call. When that host answered 522 the leg failed in 58s having mutated nothing:

DOMDocument::schemaValidate(https://schema.phpunit.de/13.1/phpunit.xsd):
Failed to open stream: HTTP request failed! HTTP/1.1 522

It is now the vendored relative path, which is what phpunit --generate-configuration writes itself: it prefers vendor/phpunit/phpunit/phpunit.xsd and only falls back to the URL when the file is absent. phpunit --migrate-configuration agrees, returning early on a relative path and rewriting any URL to the installed series.

The URL also pinned 13.1 while this package installs PHPUnit 13.3, so the schema did not describe the installed version even when reachable.

Result: Mutation testing now passes in 1m4s instead of failing at 58s. All 13 checks are green.

PHP 8.6 emits two E_WARNINGs at class declaration when a
SessionHandlerInterface implementation omits create_sid() and validateId(),
and phpunit's failOnWarning turns them into a red suite even in tests that
never open a session.

Implement SessionIdInterface and SessionUpdateTimestampHandlerInterface, and
add the three methods. updateTimestamp() delegates to write() because under
8.6 strict mode an unchanged session closes through it instead of write().

Also available from PHP 9.0 onward, where SessionHandlerInterface declares
both methods itself.
SessionHandlerInterface::gc() declares $max_lifetime, from PHP 8.0 onward
PHP 9.0 keeps the same name. The implementation called it $maxLifetime,
which mago reports as incompatible-parameter-name.

Named arguments make the parameter name part of the callable contract, so
a caller writing $handler->gc(max_lifetime: 60) fails against this class.
PHP's own session module calls positionally, which is why this has been
latent rather than broken.

The rename needs a lint suppression: max_lifetime is snake case and
variable-name wants camel case, but the interface fixes the spelling.
mago's own suggestion, renaming back to $maxLifetime, would restore the
defect. The suppression follows the existing pattern used for create_sid
on this branch.
@codecov

codecov Bot commented Oct 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The pgsql fixture hostname was the docker service name, so it only
resolved inside the compose network. The mysql fixture already used
127.0.0.1, which resolves on the host and in a runner alike, and that is
the convention the rest of the file follows.

Changing it lets the pgsql adapter reach the server on both platforms.
Before, a local run failed at name resolution:

    could not translate host name pgsql to address

and now it connects to 127.0.0.1 on the default port, surfacing the real
credential result instead of a network error.

No CI impact. webware-ci.json sets TESTS_PHPDB_ADAPTER_PGSQL to 0 and
runs mysql:9.7, so this default never applies in CI.
Every integration adapter now resolves through the same shape: a
127.0.0.1 host, the engine default port, a webware database, and a
webware/webware credential pair. Previously mysql used root with an empty
password and pgsql used the postgres superuser, so neither matched the
other, and pgsql additionally needed credentials a developer is unlikely
to have.

The sqlite DSN carried a hardcoded tmp path, which does not exist on
Windows. The DSN may now contain a :TMP: token, expanded through a single
SqliteDsn helper so the fixture loader and the adapter cannot disagree
about the path.

Setup for the two server engines is scripted and idempotent. Each script
creates the webware role, the webware database, and the grants the fixture
loader needs to drop and recreate its schema. The scripts stay outside the
repository.

Placeholder values only. TEST_ENV_JSON still overrides every one of these
in CI, so the values here never reach a runner.
The fixture loader dropped and recreated the whole database, connecting to
the postgres database to do it. An ordinary role cannot do that: DROP
DATABASE cannot run while connected to the target, WITH (FORCE) needs
pg_signal_backend to terminate other backends, and CREATE DATABASE needs
CREATEDB. Together those forced the suite to run as a superuser, which is
the opposite of what the connection settings are for.

Nothing in the suite needs the database recreated. Every test creates its
own table, asserts against the catalog, and drops it again, so the database
only has to exist and be reachable. Recreating the public schema is
equivalent and needs only ownership of the database.

This is also why the integration suite could not pass locally before: the
loader reported permission denied to terminate process, and the role had no
CREATEDB.
The container was started with MYSQL_ALLOW_EMPTY_PASSWORD and
MYSQL_DATABASE only, so the image provisioned root and nothing else. The
phpunit defaults now ask for webware/webware on both server engines, which
matched pgsql but had no counterpart in the mysql container, so every leg
failed with access denied for user webware@172.17.0.1.

MYSQL_USER and MYSQL_PASSWORD are the image's own provisioning path, so no
extra step is needed. Verified against mysql:9.7 with exactly this env: the
user is created with access to the webware database and can run DDL.

This keeps the engine definitions symmetrical end to end, matching the
pgsql role script, rather than having one engine provision a role and the
other rely on root.
The previous loader dropped the database and created it again, which
requires FORCE and therefore pg_signal_backend plus CREATEDB, so the test
role had to be effectively superuser.

Mirror the mysql loader shape instead: check pg_database and create only
when the database is missing. Postgres has no CREATE DATABASE IF NOT
EXISTS, so existence is read from pg_database first. CI already
provisions the database through the container environment, making this a
no-op there.

At teardown the suite's own connection is still open, and Postgres
refuses to drop a database with a session attached while a non-superuser
cannot terminate its own backend. dropDatabase() now tolerates only
SQLSTATE 55006 and rethrows anything else. That is safe because the
outcome the drop exists to achieve is already guaranteed: CI uses a
disposable container per leg, and each test creates and drops its own
table, so no state carries between runs.
@tyrsson
tyrsson force-pushed the fix/session-handler-86-forward-compat branch from b3efad7 to d42989d Compare October 21, 2026 17:13
Infection validates phpunit.xml.dist against xsi:noNamespaceSchemaLocation
before it runs a single mutant. With an https URL that validation is a
network fetch, so the required mutation job failed in 58s without running
a mutant once:

  DOMDocument::schemaValidate(https://schema.phpunit.de/13.1/phpunit.xsd):
  Failed to open stream: HTTP request failed! HTTP/1.1 522

The URL also pinned the 13.1 series while this package installs PHPUnit
13.3, so the schema no longer described the installed version.

Use the relative path instead. It resolves inside the installed package,
needs no network, and always matches the installed version. This is the
value phpunit --generate-configuration writes itself: it prefers
vendor/phpunit/phpunit/phpunit.xsd and only falls back to the URL when
that file is absent.

Verified offline: the relative path validates the config with 0 errors
while the URL fails to locate the schema resource.
@tyrsson
tyrsson merged commit 3b33f7d into 1.0.x Oct 21, 2026
12 checks passed
@tyrsson
tyrsson deleted the fix/session-handler-86-forward-compat branch October 21, 2026 17:27
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