Skip to content

Guard the vendor autoload include against symlink double-loading#384

Closed
epeicher wants to merge 4 commits into
trunkfrom
fix-wpcloud-symlink-double-autoload
Closed

Guard the vendor autoload include against symlink double-loading#384
epeicher wants to merge 4 commits into
trunkfrom
fix-wpcloud-symlink-double-autoload

Conversation

@epeicher

@epeicher epeicher commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

What it does

Requires the plugin's Composer autoloader by its realpath(), so the same physical autoload.php can no longer be included twice through two different path strings.

Rationale

When wp-content is a symlink (the WP Cloud layout), the plugin's files are reachable through two path families — the symlink path and the realpath. With opcache.revalidate_path=0 (the default), require_once does not dedupe across the two strings, so a cross-family double include of autoload.php re-declares the ComposerAutoloaderInit* class:

PHP Fatal error: Cannot redeclare class ComposerAutoloaderInitc5be971...
  previously declared in /srv/<site>/wp-content/plugins/site-export/vendor/composer/autoload_real.php   ← symlink family
  in                     /tmp/<content>/wp-content/plugins/site-export/vendor/composer/autoload_real.php ← realpath family

The export endpoint answers HTTP 500 and, in CI, import-38-flatten-wpcloud cascades (files-sync fails, nothing lands in the fs root, flat-docroot reports "ABSPATH not found"). Which leg fails varies with per-file opcache cache-key state — it hit PHP 8.1 twice, then 8.4 — the same class of symlink double-load afc8318 (#148) fixed inside export.php.

A temporarily instrumented CI run (since reverted) captured the fatal in flight and pinned the mechanism:

  • the plugin bootstraps once through the symlink family, but Composer's baked __DIR__ paths resolve symlinks, so everything the ClassLoader itself pulls in — classmap classes, and eventually the colliding second autoload.php include — runs through the realpath family;
  • a symbol guard (function_exists on a Composer files entry, the first attempt in this PR) is unsound: the trace showed the loader re-entered mid-getLoader(), when the ComposerAutoloaderInit* class is already declared but the files entries have not run yet.

Requiring the realpath puts this loader's include in the same family Composer already uses, so require_once dedupes natively whichever side runs first — no reliance on load-order symbols, no dependence on who the second includer is.

Testing instructions

The failure is opcache- and version-dependent, so the E2E matrix is the practical test: import-38-flatten-wpcloud exercises exactly this layout. With the fix, the full matrix went green (run failed on 8.4 before the fix; the follow-up runs pass) — the flake had fired on every recent PR run, including twice on the same leg of #383. php -l reprint-exporter-wp/lib.php passes; behaviour is unchanged when no symlinks are involved (realpath is then the same path).

When wp-content is reached through a symlink, the same physical
autoload.php can be included through two different paths. With
opcache.revalidate_path=0 (the default), require_once does not resolve
symlinks for deduplication, so the second include re-declares the
ComposerAutoloaderInit* class and fatals, taking the export endpoint
down with HTTP 500.

Same mechanism afc8318 fixed for utils.php and class-http-server.php in
export.php; the vendor/autoload.php include in the plugin loader was the
remaining unguarded one. Guard on build_pdo_dsn(): it is a Composer
`files` autoload entry, so it exists exactly when the autoloader has
already run.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Pull pipeline performance — large-directory

Site: large-directory · 2,000+ plus targeted file-transfer scenarios files · 10,000 posts · 25,000 postmeta · PHP 8.5.8

Stage PR trunk Δ Status Details
playground-sqlite-db-pull 9.39 s 9.43 s ⚪ -40 ms (-0.4%) condition=db-pull in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=lexer
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=selected
trunk: condition=db-pull in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=lexer
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=selected
playground-sqlite-db-apply 3.62 s 3.65 s ⚪ -28 ms (-0.8%) condition=db-apply to SQLite in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=parser
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=verified
native_ast=WP_MySQL_Native_Parser_Node
sqlite_driver_parser=verified
trunk: condition=db-apply to SQLite in PHP.wasm
runtime=php.wasm 8.3
wp_mysql_parser=enabled
mode=parser
native_lexer=verified
native_token_stream=WP_MySQL_Native_Token_Stream
native_token_count=18
native_parser=verified
native_ast=WP_MySQL_Native_Parser_Node
sqlite_driver_parser=verified
Total 13.01 s 13.08 s ⚪ -68 ms (-0.5%)

Numbers carry runner noise; treat single-run deltas as directional, not authoritative.

📈 Trunk performance history — commit-by-commit timeline.

epeicher added 3 commits July 21, 2026 19:34
Gated to SITE_EXPORT_TEST_MODE + the wpcloud vhost. Logs include origin
at each bootstrap point, autoloader-init classes already declared, the
loader guard's decision, and — via a shutdown hook, which still runs
after a fatal — the ordered include list at the moment of death. To be
reverted once the second include route is identified.
The E2E trace showed the function_exists guard is unreliable: the loader
re-enters mid-getLoader(), when the ComposerAutoloaderInit* class is
already declared but the Composer `files` entries have not yet defined
build_pdo_dsn. It also showed the colliding include arrives through the
realpath family — Composer's baked __DIR__ paths resolve symlinks — while
this loader required through the symlink family, and require_once cannot
dedupe across the two strings.

Requiring the realpath puts this include in the same family as every
resolved include, so require_once dedupes natively whichever side runs
first, with no reliance on load-order symbols.
The trace they produced identified the collision: the second autoload
include arrives through the realpath family via Composer's baked __DIR__
paths, while the loader required through the symlink family. Fixed by
requiring the realpath in the previous commit.
@epeicher

Copy link
Copy Markdown
Collaborator Author

Closing this PR: CI traces showed the fatal is not fixable at the plugin's include site. Both attempts (symbol guard, then requiring the autoloader by realpath) still reproduced the crash, because with opcache.revalidate_path=0 the same file reached through a symlink and its realpath gets two opcache identities, so require_once can't dedupe them. The real fix belongs in the E2E infrastructure (e.g. opcache.revalidate_path=1 for the FPM pools), not in lib.php.

@epeicher epeicher closed this Jul 21, 2026
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