Ship pcov alongside Xdebug, disabled until a caller asks for it #37

Merged
geusebio merged 3 commits from feat/pcov-extension into main 2026-08-08 00:44:38 +00:00
Owner

Adds the pcov extension to the PHP images so that consumers collecting line coverage stop paying Xdebug's price for it. Xdebug stays installed everywhere.

Measured in this image at PHP 8.5, on a function-call-heavy workload (best of 3):

Configuration Time vs baseline
no coverage driver 0.009s 1x
Xdebug loaded, mode=debug,profile,coverage, collecting nothing 0.127s 14x
Xdebug collecting coverage 0.307s 34x
pcov collecting coverage 0.060s 6.7x
pcov loaded, pcov.enabled=0 0.008s free
pcov loaded, pcov.enabled=1, not collecting 0.033s 3.7x

It ships disabled

pcov.enabled=0. Rows 5 and 6 are why: loaded-but-disabled costs nothing measurable, while leaving it enabled taxes every run that is not collecting coverage by ~3.7x — and coverage is the rare case.

Opting in is the whole switch, because php-code-coverage's Selector::forLineCoverage() tests hasPCOV() before hasXdebug(), and hasPCOV() is exactly extension_loaded('pcov') && ini_get('pcov.enabled'):

php -d pcov.enabled=1 vendor/bin/phpunit --coverage-clover clover.xml

One caveat worth knowing before you use this: -d does not reach ParaTest's worker processes. Under ParaTest you need --passthru-php="-d pcov.enabled=1", and XDEBUG_MODE=off as an environment variable, which does inherit.

Coverage per variant

Variant pcov Why
Alpine 8.5 / 8.4 / 8.3 / 8.2 yes php8X-pecl-pcov in their branch's community repo
Alpine 8.1 no 3.18 has it only in edge/testing; not worth the libc skew on a 3.18 base
Alpine 7.4 no 3.15 packages none for php7, in any repo
Ubuntu, all versions incl. 7.4 yes ondrej ships php7.4-pcov

conf.d is one tree shared by every variant, so 51_pcov.ini would otherwise land in the 8.1 and 7.4 images and make PHP warn about an unloadable library on every invocation. PHP.fragment.Dockerfile drops it wherever the .so is genuinely absent.

That probe uses find rather than asking PHP for its extension_dir. This is deliberate and was a real bug first: the step runs before FIX_PERMS symlinks /usr/bin/php, so shelling out to php there silently yields nothing, reads as "not packaged", and drops the ini on the very versions that do have the package.

Testing

validate.d/php.sh gains an assertion that the extension and its ini agree, and that pcov is disabled by default. It is keyed on whether the .so is on disk rather than on the ini — keying it on the ini lets the interesting failure through, since a bad probe leaves both absent and self-consistent on an image whose entire point was to ship pcov. That assertion was added first, missed the bug above, and was then rewritten; it now catches it.

Verified on clean --no-cache builds of all six Alpine variants plus Ubuntu 7.4: pcov loaded where expected, ini absent where not, pcov.enabled '0' throughout, Xdebug still present, and no startup warnings anywhere.

Unrelated pre-existing issue noticed while testing the Ubuntu side: ubuntu-php-* fails its own validate on Nginx version 1.24 does not match expected version 1.28, which is presumably why those assertions are commented out in test.yml. Not touched here.


Measured on a real suite, and one trap found

Ran Grey.ooo/Someones.Computer's suite (1877 tests, 5541 assertions — identical test outcomes across every run) in the CI runner image with pcov installed the way this PR ships it:

Configuration Wall statements covered
Xdebug (what CI does today) 91s 6881 6847 baseline
pcov, opcache as shipped 37s 6876 6814 ✗ 33 covered lines lost
pcov, -d opcache.file_cache= 34s 6876 6814 ✗ no better
pcov, -d opcache.enable_cli=0 35s 6882 6847 ✓ exact parity

pcov silently under-reports coverage when opcache is enabled in CLI. It builds its line table when a file is compiled, and a file served out of opcache is never compiled — so pcov never sees it. There is no warning and no error, just a lower number that reads exactly like a coverage regression. Disabling only the file cache does not help; it has to be opcache.enable_cli=0, which costs nothing measurable.

With that set, the two drivers agree on covered lines exactly.

Verified end to end against Grey.ooo/Someones.Computer's main, which sits at 100% and so is the case that actually matters:

Driver Wall Line coverage Gate
Xdebug (what CI does today) 81s 100.00% (6475/6475) passes
pcov + opcache.enable_cli=0 33s 100.00% (6475/6475) passes

Identical numerator and denominator, gate green under both, 2.45x faster.

One caveat worth carrying: the drivers do not always classify the same lines as executable. On a feature branch of that same codebase, where a throw inside an if was never executed, pcov counted it as an executable statement and Xdebug did not — which moves the denominator. So diff the totals before trusting a gate on them, rather than assuming parity.

Both facts are now written into 51_pcov.ini and the README, since neither is discoverable from a failure message.

Under ParaTest the flags are needed in both the parent and the workers — the parent will not start without a driver of its own, and -d does not propagate to the workers that do the collecting:

XDEBUG_MODE=off php -d pcov.enabled=1 -d opcache.enable_cli=0 \
  vendor/bin/paratest --passthru-php="-d pcov.enabled=1 -d opcache.enable_cli=0" \
  --coverage-clover clover.xml

Net effect for the consumer that prompted this: 91s → 35s, on a 16-core workstation. On the contended CI runner where Xdebug was overrunning a 900s Composer timeout, the margin should be considerably larger.

Adds the `pcov` extension to the PHP images so that consumers collecting line coverage stop paying Xdebug's price for it. Xdebug stays installed everywhere. Measured in this image at PHP 8.5, on a function-call-heavy workload (best of 3): | Configuration | Time | vs baseline | |---|---|---| | no coverage driver | 0.009s | 1x | | Xdebug loaded, `mode=debug,profile,coverage`, **collecting nothing** | 0.127s | 14x | | Xdebug collecting coverage | 0.307s | 34x | | **pcov collecting coverage** | **0.060s** | **6.7x** | | pcov loaded, `pcov.enabled=0` | 0.008s | free | | pcov loaded, `pcov.enabled=1`, not collecting | 0.033s | 3.7x | ## It ships disabled `pcov.enabled=0`. Rows 5 and 6 are why: loaded-but-disabled costs nothing measurable, while leaving it enabled taxes every run that is *not* collecting coverage by ~3.7x — and coverage is the rare case. Opting in is the whole switch, because php-code-coverage's `Selector::forLineCoverage()` tests `hasPCOV()` before `hasXdebug()`, and `hasPCOV()` is exactly `extension_loaded('pcov') && ini_get('pcov.enabled')`: ``` php -d pcov.enabled=1 vendor/bin/phpunit --coverage-clover clover.xml ``` One caveat worth knowing before you use this: `-d` does **not** reach ParaTest's worker processes. Under ParaTest you need `--passthru-php="-d pcov.enabled=1"`, and `XDEBUG_MODE=off` as an environment variable, which does inherit. ## Coverage per variant | Variant | pcov | Why | |---|---|---| | Alpine 8.5 / 8.4 / 8.3 / 8.2 | yes | `php8X-pecl-pcov` in their branch's community repo | | Alpine 8.1 | **no** | 3.18 has it only in `edge/testing`; not worth the libc skew on a 3.18 base | | Alpine 7.4 | **no** | 3.15 packages none for `php7`, in any repo | | Ubuntu, all versions incl. 7.4 | yes | ondrej ships `php7.4-pcov` | `conf.d` is one tree shared by every variant, so `51_pcov.ini` would otherwise land in the 8.1 and 7.4 images and make PHP warn about an unloadable library on *every* invocation. `PHP.fragment.Dockerfile` drops it wherever the `.so` is genuinely absent. That probe uses `find` rather than asking PHP for its `extension_dir`. This is deliberate and was a real bug first: the step runs before `FIX_PERMS` symlinks `/usr/bin/php`, so shelling out to `php` there silently yields nothing, reads as "not packaged", and drops the ini on the very versions that *do* have the package. ## Testing `validate.d/php.sh` gains an assertion that the extension and its ini agree, and that pcov is disabled by default. It is keyed on whether the `.so` is on disk rather than on the ini — keying it on the ini lets the interesting failure through, since a bad probe leaves both absent and self-consistent on an image whose entire point was to ship pcov. That assertion was added first, missed the bug above, and was then rewritten; it now catches it. Verified on clean `--no-cache` builds of all six Alpine variants plus Ubuntu 7.4: pcov loaded where expected, ini absent where not, `pcov.enabled` `'0'` throughout, Xdebug still present, and no startup warnings anywhere. Unrelated pre-existing issue noticed while testing the Ubuntu side: `ubuntu-php-*` fails its own `validate` on `Nginx version 1.24 does not match expected version 1.28`, which is presumably why those assertions are commented out in `test.yml`. Not touched here. --- ## Measured on a real suite, and one trap found Ran Grey.ooo/Someones.Computer's suite (1877 tests, 5541 assertions — identical test outcomes across every run) in the CI runner image with pcov installed the way this PR ships it: | Configuration | Wall | statements | covered | | |---|---|---|---|---| | Xdebug (what CI does today) | 91s | 6881 | 6847 | baseline | | pcov, opcache as shipped | 37s | 6876 | **6814** | ✗ 33 covered lines lost | | pcov, `-d opcache.file_cache=` | 34s | 6876 | **6814** | ✗ no better | | **pcov, `-d opcache.enable_cli=0`** | **35s** | 6882 | **6847** | ✓ exact parity | **pcov silently under-reports coverage when opcache is enabled in CLI.** It builds its line table when a file is compiled, and a file served out of opcache is never compiled — so pcov never sees it. There is no warning and no error, just a lower number that reads exactly like a coverage regression. Disabling only the file cache does *not* help; it has to be `opcache.enable_cli=0`, which costs nothing measurable. With that set, the two drivers agree on covered lines **exactly**. Verified end to end against Grey.ooo/Someones.Computer's `main`, which sits at 100% and so is the case that actually matters: | Driver | Wall | Line coverage | Gate | |---|---|---|---| | Xdebug (what CI does today) | 81s | 100.00% (6475/6475) | passes | | **pcov + `opcache.enable_cli=0`** | **33s** | **100.00% (6475/6475)** | **passes** | Identical numerator *and* denominator, gate green under both, **2.45x faster**. One caveat worth carrying: the drivers do not always classify the same lines as executable. On a feature branch of that same codebase, where a `throw` inside an `if` was never executed, pcov counted it as an executable statement and Xdebug did not — which moves the denominator. So diff the totals before trusting a gate on them, rather than assuming parity. Both facts are now written into [`51_pcov.ini`](fs/php-nginx/etc/php/conf.d/51_pcov.ini) and the README, since neither is discoverable from a failure message. Under ParaTest the flags are needed in **both** the parent and the workers — the parent will not start without a driver of its own, and `-d` does not propagate to the workers that do the collecting: ``` XDEBUG_MODE=off php -d pcov.enabled=1 -d opcache.enable_cli=0 \ vendor/bin/paratest --passthru-php="-d pcov.enabled=1 -d opcache.enable_cli=0" \ --coverage-clover clover.xml ``` Net effect for the consumer that prompted this: **91s → 35s**, on a 16-core workstation. On the contended CI runner where Xdebug was overrunning a 900s Composer timeout, the margin should be considerably larger.
Ship pcov alongside Xdebug, disabled until a caller asks for it
Some checks failed
Build / Run container tests (push) Failing after 13m47s
Build / Build (push) Has been cancelled
b9a8b10a5d
Collecting line coverage with Xdebug is the slowest way to do it, and it is
what every consumer of these images currently has to use. Measured in this
image at PHP 8.5, on a function-call-heavy workload:

  no driver                         0.009s
  Xdebug loaded, collecting         0.307s   34x
  pcov loaded, collecting           0.060s    6.7x
  pcov loaded, pcov.enabled=0       0.008s    free

pcov does line coverage only, which is all a --coverage-clover run reads, so
for the common case it is a straight 5x on the collection itself.

It ships disabled. That last row is the reason: loaded-but-disabled costs
nothing measurable, whereas leaving it enabled taxes every run that is *not*
collecting coverage by ~3.7x, and coverage is the rare case. Callers opt in
per command with -d pcov.enabled=1, which is the whole switch --
php-code-coverage's Selector::forLineCoverage() tests hasPCOV() before
hasXdebug(), and hasPCOV() is exactly extension_loaded('pcov') &&
ini_get('pcov.enabled'). Note that this does not reach paratest's workers on
its own; those need --passthru-php.

Xdebug stays installed everywhere. It is still wanted for step debugging, and
for branch and path coverage, which pcov does not implement.

Alpine 8.1 and 7.4 do not get it: 3.18 packages php81-pecl-pcov only in
edge/testing, and 3.15 packages none for php7 at all. Since conf.d is one tree
shared by every variant, 51_pcov.ini would otherwise land in those two images
and make PHP warn about an unloadable library on every invocation, so the
fragment drops it wherever the .so is genuinely absent. That probe uses `find`
rather than asking PHP for its extension_dir, because it runs before FIX_PERMS
symlinks /usr/bin/php and would otherwise silently drop the ini on the very
versions that do have the package. The Ubuntu images get pcov on every
version, 7.4 included, since ondrej ships php7.4-pcov.

validate.d/php.sh asserts the extension and its ini agree, keyed on whether
the .so is on disk rather than on the ini -- keying it on the ini lets the
interesting failure through, since a bad probe leaves both absent and
self-consistent on an image whose whole point was to ship pcov.
Document the opcache trap that silently eats pcov's coverage
Some checks failed
Build / Build (push) Has been cancelled
Build / Run container tests (push) Has been cancelled
d78b8e3b05
Measuring the switch on a real 1877-test suite turned up a failure mode
worth writing down, because it is silent and it looks exactly like a
coverage regression:

  pcov, opcache as shipped     6876 statements, 6814 covered
  pcov, opcache.file_cache=    6876 statements, 6814 covered
  pcov, opcache.enable_cli=0   6882 statements, 6847 covered
  Xdebug                       6881 statements, 6847 covered

pcov builds its line table when a file is compiled, and a file served out
of opcache is never compiled -- so pcov does not see it and reports fewer
covered lines. No warning, no error. Thirty-three lines here, which is far
more than enough to fail a 100% gate for reasons that have nothing to do
with the code under test. Disabling the file cache alone does not help; it
has to be opcache.enable_cli=0, and it is free (35s against 34s, on a run
dominated by collection).

With opcache off the two drivers agree on covered lines exactly -- 6847
against 6847, no differences in either direction. pcov counts one extra
executable statement, an untested branch Xdebug never presented to the
gate, so expect the switch to make a 100% gate stricter rather than looser.

Also records that ParaTest needs the flags in both the parent and the
workers: the parent refuses to start without a driver of its own, and -d
does not propagate to the workers that do the collecting. XDEBUG_MODE is
an environment variable and does inherit.
Correct the coverage-parity note: measure the gate, do not predict it
All checks were successful
Build / Run container tests (push) Successful in 53s
Build / Build (push) Successful in 1m39s
fbee8ab243
The previous wording claimed the switch makes a 100% gate stricter, and
cited "a genuinely untested branch the gate had never been able to see".
That came from measuring a feature branch which predated the test for it.
On main that branch is covered, and the honest numbers are:

  Xdebug                      81s   100.00%  (6475/6475)  gate OK
  pcov + opcache.enable_cli=0 33s   100.00%  (6475/6475)  gate OK

Identical in both the numerator and the denominator, so nothing tightens
here at all. What survives is narrower and worth keeping: the two drivers
do not always agree on which lines are executable -- on the branch where
that throw never ran, pcov counted it and Xdebug did not -- so the
denominator can move and a gate can tighten. That is a reason to diff the
totals before trusting the gate, not a prediction that it will fail.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Grey.ooo/docker!37
No description provided.