Repository navigation
fix(session): satisfy the PHP 8.6 session handler contract and align gc() with the interface - #15
Merged
Merged
Conversation
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 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
force-pushed
the
fix/session-handler-86-forward-compat
branch
from
October 21, 2026 17:13
b3efad7 to
d42989d
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven commits:
DbSessionHandlercontract 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 aSessionHandlerInterfaceimplementation omitscreate_sid()andvalidateId(), and phpunit'sfailOnWarningturns them into a red suite even in tests that never open a session.Implements
SessionIdInterfaceandSessionUpdateTimestampHandlerInterfaceand adds the three methods.updateTimestamp()delegates towrite()because under 8.6 strict mode an unchanged session closes through it instead ofwrite().Also correct from PHP 9.0 onward, where
SessionHandlerInterfacedeclares both methods itself.gc() parameter name
SessionHandlerInterface::gc()declares$max_lifetime. This implementation called it$maxLifetime, which mago reports asincompatible-parameter-name: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_lifetimeis snake case andvariable-namewants 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 forcreate_sidon 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:
phpunit.xml.distusedrootwith no password for mysql, while pgsql and sqlite each had their own dialect of the same idea. All three now use databasewebware, userwebware, passwordwebware, on127.0.0.1with their usual ports. That is the same shape a contributor, the docker services and the CI container all use.SqliteDsnhelper expands a:TMP:token in the DSN so the two cannot disagree about which file is under test.127.0.0.1like mysql rather than through a service name that only exists inside a container network.CI had to follow:
webware-ci.jsonnow passesMYSQL_USERandMYSQL_PASSWORDto the database container, so thewebwareuser exists there too. Without that the legs fail withAccess denied for user 'webware'@'172.17.0.1'. Verified against a realmysql:9.7container 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 DATABASEwhile connections are open; PostgreSQL does not. Dropping needsWITH (FORCE), which needspg_signal_backend, and creating needsCREATEDB, so the test role would have to be effectively superuser. There is noCREATE DATABASE IF NOT EXISTSin Postgres either, so existence is read frompg_databasefirst 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 SQLSTATE55006and 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 thepublicschema instead;b3efad7replaced it.Verification
mago format --check: cleanmago lint: cleanmago analyze: 8 issues, down from 9, theincompatible-parameter-namewarning gonemago guard: cleancomposer test: 70 tests, 141 assertions, OKcomposer test-integration: 30 tests, 73 assertions, OK, verified twice: once with thewebwaredatabase already present, and once after dropping it so the create path is exercisedf299fd7Still open in webware-phpdb
These were isolated from a
mago analyzerun and are not addressed here:mixed-assignmentsrc/Session/SessionPayload.php:37unserialize()returns mixedmixed-assignmentsrc/Session/DbSessionHandler.php:96mixed-assignmentsrc/Session/DbSessionHandler.php:97mixed-assignmentsrc/Session/PhpDbSessionPersistence.php:135redundant-comparisonx2,redundant-conditionx2src/SchemaFactory.php:96,:143The four
SchemaFactoryfindings are wrong. mago resolves$schema::NAMEthroughSchemaInterface::NAME = ''and ignores that implementing enums override it (test/unit/WebwareSchema.php:13setsNAME = 'webware'). Verified at runtime that the expression yields'webware', so the comparison is not redundant.Worth knowing: the
--potentially-unsafefix for those would rewrite both lines to$enumSchema = null;, silently dropping the only read of$schema::NAME. Safe-level--fixapplies nothing. They should be suppressed, not fixed.Fix the phpunit schema pointer
phpunit.xml.distpointedxsi:noNamespaceSchemaLocationathttps://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:It is now the vendored relative path, which is what
phpunit --generate-configurationwrites itself: it prefersvendor/phpunit/phpunit/phpunit.xsdand only falls back to the URL when the file is absent.phpunit --migrate-configurationagrees, 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.