Skip to content

Lock Composer autoload around phar:// reads in forked workers#5670

Open
ondrejmirtes wants to merge 3 commits into
2.1.xfrom
pcntl-fork-phar-autoload-lock
Open

Lock Composer autoload around phar:// reads in forked workers#5670
ondrejmirtes wants to merge 3 commits into
2.1.xfrom
pcntl-fork-phar-autoload-lock

Conversation

@ondrejmirtes
Copy link
Copy Markdown
Member

Alternative to #5669. Same problem (PHP's built-in phar:// wrapper
caches one fd, after pcntl_fork() parent and forked children share
its OFD, concurrent class loads race on the seek cursor and produce
spurious parse errors). Different fix: wrap every registered Composer
ClassLoader so loadClass() takes an exclusive flock on a tmp file
before its include and releases it after. Two workers can never be
inside include 'phar://…' simultaneously, so the cursor can't be
moved out from under either of them.

Cost

One flock pair per autoload. Each worker only contends with siblings
during its initial loading wave — a few hundred classes touched once.
After its symbol table is populated the lock is never taken again and
workers run fully parallel for the rest of the analysis.

Scope

Covers only class autoload. Non-class phar:// reads
(file_get_contents, stat, dir iteration) still go through the
unlocked built-in wrapper. In practice those happen during boot, which
is pre-fork, so they don't race. If a lazy non-class phar read does
fire post-fork, #5669 (extract-and-reroute) is the comprehensive fix.

Wiring

ParallelAnalyser and FixerApplication call the (idempotent)
install() once they have decided to take the fork path. No-op when
not running inside a phar. Cleanup hook unlinks the lock tmpfile on
parent shutdown.

🤖 Generated with Claude Code

ondrejmirtes and others added 3 commits May 15, 2026 11:06
Alternative approach to making the experimental pcntl_fork() worker
path work when PHPStan is run from a .phar. Where #5669 extracts the
phar before forking and reroutes every phar:// read to disk, this
takes the opposite tack: leave PHP's built-in phar:// wrapper in
place but serialise the racy operation.

PHP's built-in phar:// stream wrapper caches a single fd for the
running .phar; after pcntl_fork() that fd's open file description
(and its seek cursor) is shared between parent and every forked
child, so concurrent lazy class loads across workers can interleave
and read garbage offsets — surfacing as spurious parse errors against
phar-internal files.

PharAutoloaderLock::install() wraps every registered Composer
ClassLoader so loadClass() acquires an exclusive flock on a tmp file
before its `include` and releases it after. Two workers can never
hold the lock simultaneously, so they can never be inside
`include 'phar://…'` simultaneously, so the cursor can't be moved
out from under either of them.

Cost: per-class load takes one flock pair. Each worker contends with
siblings only during its initial "loading wave" — a few hundred
classes touched once. After its symbol table is populated the lock
is never taken again and workers run fully parallel for the rest of
the analysis.

Covers only class autoload. Non-class phar reads
(file_get_contents('phar://…'), stat, dir iteration) still go through
the unlocked built-in wrapper; in practice those happen during boot,
which is pre-fork, so they don't race.

ParallelAnalyser and FixerApplication call the (idempotent) install()
once they have decided to take the fork path. No-op when not running
inside a phar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… reference

Referencing Composer\Autoload\ClassLoader makes php-scoper rewrite it
to the prefixed _PHPStan_xxx\Composer\Autoload\ClassLoader form when
building the phar, but Composer's autoloader is exempted from prefixing
by the scoper patcher whitelist in compiler/build/scoper.inc.php (it
lives at the unscoped name at runtime). So the prefixed reference
resolves to a non-existent class and PharAutoloaderLock::install()
fatals on first call.

Switch to spl_autoload_functions() / spl_autoload_register /
spl_autoload_unregister, working with the registered callables
directly. No reference to Composer's namespace, no dependency on
the scoper patcher's whitelist, no Composer-version assumptions
(getRegisteredLoaders() is 2.0+).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ted autoload

PHP class declaration can trigger a nested autoload inside the
declaration itself — `class Foo extends Bar` autoloads Bar mid-bind.
With each call doing its own `fopen` + `flock(LOCK_EX)`, the nested
call opens a separate OFD against the same lockfile and tries to
acquire LOCK_EX while the outer call holds it; flock is per-OFD on
BSD/macOS, so the same process self-deadlocks. Symptom: 0/N files
progress and every worker stuck inside `zif_flock` according to
sample(1).

The race that actually matters is cross-process — within one process,
libphar reads are sequential by construction, so the nested call
doesn't need a second lock. Track depth per-process: outermost call
takes the flock, nested ones (depth > 0) skip straight to the
delegate. Cross-process exclusion is preserved because the static
counter is per-process (fork COWs its own copy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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