Commit a603110fb58 for woocommerce
commit a603110fb58e264370db52cc20e9b93410372e44
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Jun 11 13:13:18 2026 +0300
Fix PSR-4 upgrade fallback fataling on a foreign loader or torn file (#65630)
* fix(autoloader): degrade gracefully on a foreign loader shape or torn file
register_woocommerce_psr4_fallback() called getPrefixesPsr4() outside the try/catch that build_woocommerce_psr4_fallback() relies on, and the registered handler's require_once was unguarded. So a foreign Composer ClassLoader shape, or a torn/partially-written class file extracted mid in-place-upgrade, would fatal the request — contradicting build()'s documented graceful-degradation contract. The torn-file case is the practical one: a defensive class_exists('Automattic\\WooCommerce\\...') probe during an upgrade would hit an uncatchable ParseError from the include instead of simply getting false.
Extend the Throwable guard to both spots: wrap build() + getPrefixesPsr4() so a foreign shape returns null (no fallback registered), and wrap the handler's require_once so a torn file leaves the class unresolved. Update the docblock to state the full degradation contract. Add a regression test that resolves a deliberately torn src file and asserts the handler degrades rather than letting the ParseError escape (reproduced on trunk before the fix).
* chore: add changelog entry for the PSR-4 fallback degrade guards
* fix(autoloader): keep a torn-file miss retryable within the request
The PSR-4 fallback handler guarded its require_once with a Throwable
catch so a torn/partially-written file mid-upgrade degrades to a miss
instead of fataling. But PHP records a path in the included-files table
BEFORE compiling it for the *_once variants, so the caught ParseError
still marked the file as included: when the upgrade finished writing the
file and a later probe in the same request resolved it again, the
require_once no-op'd and the class stayed unresolvable for the rest of
the request — contradicting the mid-request recovery guarantee the
fallback exists for. require_once would also fatal uncatchably if the
file vanished between findFile() and the include (upgrade file swap).
Switch the handler to a plain include, which records the path only on
SUCCESSFUL compilation: a parse-failed attempt stays retryable, and a
vanished file degrades to a warning instead of a fatal. Plain include
loses require_once's protection against re-executing an
already-included file — an uncatchable "Cannot redeclare class" fatal
(e.g. repeated probes of a class whose resolved file declares a
different name) — so guard with the engine's own included-files table,
which records exactly the successful inclusions that must be skipped.
Both regression tests reproduce against the require_once version: the
retry test fails (poisoned miss) and the re-execution scenario was
verified standalone to fatal through a catch (Throwable).
Trade-off: the guard copies get_included_files() per resolved miss, a
path reached only when every registered autoloader missed yet the file
exists on disk — effectively only during an upgrade window.
Refs WOOPLUG-6817
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autoloader): keep link/runtime-failed torn files retryable mid-upgrade
The PSR-4 fallback's redeclare guard skipped any path already present in
get_included_files(). A plain `include` records a path there after compilation
but BEFORE execution, so a torn file that compiles yet fails while linking (a
class whose parent/interface/trait is not written yet — common in src/) or at
runtime was recorded despite never declaring its class. The guard then skipped
the completed file for the rest of the request, reintroducing the negative-cache
failure the fallback exists to prevent. Only the parse-error variant recovered,
which is the only case the tests covered.
Track the files this handler executes itself instead: a $loaded set recorded
only after a clean include (so parse/link/runtime failures stay retryable), and
an $attempted set so a file another loader already ran is still skipped (the
get_included_files() redeclare protection is preserved unchanged). Surface a
caught error under WP_DEBUG so a genuine parse/link error in a shipped src/ file
is not an invisible class_exists() === false. Add a regression test for the
link-failed-then-completed path and soften the docblock's redeclare guarantee to
match what the guard can actually promise.
* style(autoloader): blank line before block comment to satisfy phpcs
Squiz.Commenting.BlockComment.NoEmptyLineBefore on the cross-loader guard
comment introduced in the previous commit.
* test(autoloader): cover the redeclare guard's other-mechanism skip branch
The PSR-4 fallback's redeclare guard has three exits; the suite covered
two: the $loaded skip (handler ran the file cleanly) and the $attempted
bypass that keeps a failed include retryable. The third — skipping a file
the handler never attempted but that another mechanism (the primary
autoloader, a manual require) already executed — was never exercised,
because every existing test had the handler perform the first inclusion.
That branch is the only protection against an uncatchable "Cannot
redeclare class" fatal on a re-probe, so a future simplification removing
it in favour of the handler's own bookkeeping would pass the whole suite.
Require the rogue-shape file directly in the test, then probe its PSR-4
class via the handler: the guard must skip, and a regression fatals the
PHPUnit process rather than failing an assertion (mutation-verified by
disabling the branch).
Refs WOOPLUG-6817
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(autoloader): keep a failed-open include retryable mid-upgrade
The PSR-4 fallback recorded a path in its $loaded set whenever `include`
returned without throwing. But an include that fails to OPEN the file —
deleted or unreadable between findFile()/realpath() and the include, e.g.
the upgrader replacing the plugin directory — emits a warning and returns
false rather than throwing. No Throwable reaches the catch, so the failed
attempt was recorded as loaded and every later probe skipped the path:
the restored file could never load for the rest of the request. This also
regressed the prior guard, since a failed-open include never enters
get_included_files() and therefore used to stay retryable.
Record $loaded only when include reports success. A false return means
nothing was compiled or executed, so re-including is safe (no redeclare
risk) and the path stays retryable; no first-party src/ file returns
false from a successful include (class files return 1, the lone top-level
return in src/ yields an array). Add a regression test reproducing the
failed-open shape deterministically with a directory at the class-file
path — accepted by file_exists()/realpath(), rejected by the include's
open — with a temporary error handler keeping the warning a warning, as
in production (the suite's convertWarningsToExceptions would otherwise
mask the false-return path).
Refs WOOPLUG-6817
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brandon Kraft <public@brandonkraft.com>
diff --git a/plugins/woocommerce/changelog/fix-psr4-fallback-degrade-guards b/plugins/woocommerce/changelog/fix-psr4-fallback-degrade-guards
new file mode 100644
index 00000000000..705524a3e34
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-psr4-fallback-degrade-guards
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Harden the in-place-upgrade PSR-4 fallback autoloader so its remaining unguarded paths degrade gracefully: a foreign Composer ClassLoader shape and a torn/partially-written class file mid-upgrade now leave the class unresolved instead of fataling, so a defensive class_exists() probe during an upgrade returns false rather than raising an error.
diff --git a/plugins/woocommerce/src/Autoloader.php b/plugins/woocommerce/src/Autoloader.php
index 4cd906a8791..7ad57d6e070 100644
--- a/plugins/woocommerce/src/Autoloader.php
+++ b/plugins/woocommerce/src/Autoloader.php
@@ -132,10 +132,20 @@ class Autoloader {
*
* Registration is idempotent: at most one handler is ever added per request.
*
+ * Degrades to null (nothing registered) if the Composer files are unavailable or a
+ * foreign/malformed `ClassLoader` shape is present. The handler likewise leaves a class
+ * unresolved — rather than fataling — if a resolved file is torn/unparseable mid-upgrade,
+ * so a defensive `class_exists()` probe during an upgrade gets `false` instead of an error.
+ * The failed attempt stays retryable: the handler records only the files it has executed
+ * cleanly, so once the upgrade finishes writing a file that previously failed to parse,
+ * link, or run, a later probe in the same request re-attempts and loads it. It never
+ * re-executes a path it already loaded (an uncatchable "Cannot redeclare class" fatal);
+ * it cannot, however, guard the first execution of a file that declares a class already
+ * loaded elsewhere under a non-matching PSR-4 path.
+ *
* @since 11.0.0
*
- * @return \Closure|null The registered autoloader, or null if the Composer files are
- * unavailable (nothing was registered).
+ * @return \Closure|null The registered autoloader, or null if no fallback was registered.
*/
public static function register_woocommerce_psr4_fallback(): ?\Closure {
static $registered_handler = null;
@@ -150,16 +160,98 @@ class Autoloader {
// rebuilds a throwaway loader per miss from this captured map (for performance — the map
// is read once, not on every miss). Do NOT collapse this into a shared loader or a per-miss
// build() call: either reintroduces the negative-cache bug the fresh-per-miss design avoids.
- $availability_probe = self::build_woocommerce_psr4_fallback();
- if ( null === $availability_probe ) {
+ // Wrapped so a foreign/malformed ClassLoader shape (an unexpected getPrefixesPsr4()) degrades
+ // to "no fallback" rather than fataling the bootstrap — matching build()'s own contract.
+ try {
+ $availability_probe = self::build_woocommerce_psr4_fallback();
+ if ( null === $availability_probe ) {
+ return null;
+ }
+ $psr4_entries = $availability_probe->getPrefixesPsr4();
+ } catch ( \Throwable $e ) {
return null;
}
- $psr4_entries = $availability_probe->getPrefixesPsr4();
$handler = static function ( string $class_name ) use ( $psr4_entries ) {
+ /*
+ * Paths this handler has executed, so a repeated probe never re-runs a file:
+ * - $loaded: includes that returned cleanly. Re-including one would redeclare its
+ * class — an UNCATCHABLE "Cannot redeclare class" fatal (e.g. a probe whose PSR-4
+ * file declares a different class name, then a second probe of the same path).
+ * - $attempted: every path we have tried, success or failure. Used only to tell our
+ * own failed (and therefore retryable) attempt apart from a file some other loader
+ * already executed — see the get_included_files() check below.
+ */
+ static $loaded = array();
+ static $attempted = array();
+
$file = self::find_scoped_file( $class_name, $psr4_entries );
- if ( null !== $file ) {
- require_once $file;
+ if ( null === $file ) {
+ return;
+ }
+
+ $canonical = realpath( $file );
+ if ( false !== $canonical ) {
+ // Already executed cleanly by this handler: re-including would redeclare.
+ if ( isset( $loaded[ $canonical ] ) ) {
+ return;
+ }
+
+ /*
+ * Executed by another mechanism (the primary autoloader, a manual require) but
+ * never attempted by us: re-including risks the same redeclare fatal, so skip.
+ * A path WE attempted and that threw is deliberately excluded from this check so
+ * it stays retryable once the upgrade finishes writing it.
+ */
+ if ( ! isset( $attempted[ $canonical ] ) && in_array( $canonical, get_included_files(), true ) ) {
+ return;
+ }
+ $attempted[ $canonical ] = true;
+ }
+
+ try {
+ /*
+ * Deliberately a plain `include`, NOT `require_once`: the *_once variants record
+ * a path in the engine's included-files table BEFORE compiling it, so a torn
+ * file's caught error would mark the path included and every later attempt would
+ * no-op — the completed file could never load for the rest of the request. A
+ * plain include lets us record success ourselves (in $loaded, below) only after
+ * it returns, so a file that fails to parse, link (e.g. a parent not yet written
+ * mid-upgrade), or run stays retryable. A file that vanishes between findFile()
+ * and here degrades to a warning plus a FALSE return, where require would fatal —
+ * no Throwable reaches the catch below, so the return value is the only signal
+ * that nothing was compiled or executed.
+ */
+ $included = include $file;
+
+ // A false return means the include never OPENED the file (deleted/unreadable
+ // mid-upgrade): nothing ran, so re-including is safe and the path must stay
+ // retryable — recording it as loaded would skip the restored file for the
+ // rest of the request. A successful include of a src/ file never yields false
+ // (class files return 1; the odd config file returns an array), so false here
+ // always means the open failed.
+ if ( false !== $included && false !== $canonical ) {
+ $loaded[ $canonical ] = true;
+ }
+ } catch ( \Throwable $e ) {
+ /*
+ * A torn/partially-written file mid-upgrade must not turn a class probe into a
+ * fatal: leave the class unresolved so e.g. class_exists() returns false and the
+ * request continues, instead of an uncatchable error escaping the autoload handler.
+ * Surface it under WP_DEBUG so a genuine (non-upgrade) parse/link error in a
+ * shipped src/ file is not an invisible miss.
+ */
+ if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
+ error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ sprintf(
+ 'WooCommerce PSR-4 fallback could not load %1$s for %2$s: %3$s',
+ $file,
+ $class_name,
+ $e->getMessage()
+ )
+ );
+ }
+ return;
}
};
diff --git a/plugins/woocommerce/tests/php/src/AutoloaderTest.php b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
index 3597a33aed4..0663afbe996 100644
--- a/plugins/woocommerce/tests/php/src/AutoloaderTest.php
+++ b/plugins/woocommerce/tests/php/src/AutoloaderTest.php
@@ -179,6 +179,326 @@ class AutoloaderTest extends \WC_Unit_Test_Case {
}
}
+ /**
+ * A torn/partially-written file resolved mid-upgrade must not fatal: the handler
+ * leaves the class unresolved (so e.g. class_exists() returns false) rather than
+ * letting the include's ParseError escape and kill the request.
+ *
+ * @testdox the registered handler degrades (does not fatal) on a torn class file.
+ */
+ public function test_registered_handler_degrades_on_a_torn_class_file(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproTorn' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ wp_mkdir_p( $dir );
+ // A torn / partially-written file mid-upgrade: a syntax error (unclosed class body).
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {\n" );
+ clearstatcache( true, $file );
+
+ // The handler must not let the include's ParseError escape.
+ $threw = false;
+ try {
+ $handler( $class );
+ } catch ( \Throwable $e ) {
+ $threw = true;
+ }
+
+ $this->assertFalse(
+ $threw,
+ 'Handler must not let a torn-file ParseError escape — it must degrade to a miss.'
+ );
+ $this->assertFalse(
+ class_exists( $class, false ),
+ 'A torn class file must be left unresolved, not loaded or fataled.'
+ );
+ } finally {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
+ /**
+ * A torn file that degraded to a miss must stay retryable: once the upgrade finishes
+ * writing the file, a later probe in the same request loads the class. With a
+ * `require_once` in the handler this fails — PHP records the path as included BEFORE
+ * compiling it, so the caught ParseError would poison every later attempt and the
+ * completed file could never load for the rest of the request.
+ *
+ * @testdox the registered handler loads a torn class file once it is completed.
+ */
+ public function test_registered_handler_recovers_after_a_torn_file_is_completed(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproRetry' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ wp_mkdir_p( $dir );
+ // A torn / partially-written file mid-upgrade: a syntax error (unclosed class body).
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {\n" );
+ clearstatcache( true, $file );
+
+ // First probe: degrades to a miss (covered by the torn-file test above).
+ $handler( $class );
+ $this->assertFalse( class_exists( $class, false ), 'Precondition: torn file must degrade to a miss.' );
+
+ // The upgrade finishes writing the file mid-request.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {}\n" );
+ clearstatcache( true, $file );
+
+ $handler( $class );
+ $this->assertTrue(
+ class_exists( $class, false ),
+ 'Handler must load the class once the torn file is completed — the failed attempt must not poison the retry.'
+ );
+ } finally {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
+ /**
+ * The handler must never re-execute a file that already loaded successfully: a second
+ * include of a file whose class is already declared is an UNCATCHABLE "Cannot redeclare
+ * class" fatal. Reproduced here with a file whose declared class does not match the
+ * probed name, so the first probe executes the file without resolving the class and a
+ * second probe resolves the same, already-executed file. If the guard regresses, this
+ * test fatals the PHPUnit process rather than failing an assertion.
+ *
+ * @testdox the registered handler never re-executes an already-included file.
+ */
+ public function test_registered_handler_skips_an_already_included_file(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproRogue' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ wp_mkdir_p( $dir );
+ // A rogue file: parses fine but declares a class that does not match its PSR-4 path.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Mismatch {}\n" );
+ clearstatcache( true, $file );
+
+ // First probe executes the file (declares Mismatch) without resolving Widget.
+ $handler( $class );
+ $this->assertFalse( class_exists( $class, false ), 'Precondition: the probed class must stay unresolved.' );
+ $this->assertTrue(
+ in_array( realpath( $file ), get_included_files(), true ),
+ 'Precondition: the first probe must have executed the rogue file.'
+ );
+
+ // Second probe resolves the same file again; the guard must skip it instead of
+ // re-executing (which would be an uncatchable "Cannot redeclare class" fatal).
+ $handler( $class );
+ $this->assertFalse(
+ class_exists( $class, false ),
+ 'A re-probe of an already-executed file must degrade to a miss.'
+ );
+ } finally {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
+ /**
+ * The redeclare guard must also skip a file the handler never touched but that some
+ * OTHER mechanism (the primary autoloader, a manual require) already executed. This is
+ * the get_included_files() branch of the guard: the handler's own $loaded/$attempted
+ * sets know nothing about the file, so only that check stands between a re-probe and
+ * an uncatchable "Cannot redeclare class" fatal. If the guard regresses, this test
+ * fatals the PHPUnit process rather than failing an assertion.
+ *
+ * @testdox the registered handler never re-executes a file another mechanism already loaded.
+ */
+ public function test_registered_handler_skips_a_file_another_mechanism_loaded(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproForeign' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ wp_mkdir_p( $dir );
+ // A rogue file: parses fine but declares a class that does not match its PSR-4 path,
+ // so a probe for Widget keeps resolving to it without ever declaring Widget.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Mismatch {}\n" );
+ clearstatcache( true, $file );
+
+ // Another mechanism — not the handler — executes the file (declares Mismatch).
+ require $file;
+ $this->assertTrue(
+ in_array( realpath( $file ), get_included_files(), true ),
+ 'Precondition: the file must be on record as executed outside the handler.'
+ );
+
+ // The probe resolves the same file; the guard must skip it instead of
+ // re-executing (which would be an uncatchable "Cannot redeclare class" fatal).
+ $handler( $class );
+ $this->assertFalse(
+ class_exists( $class, false ),
+ 'A probe of a file another mechanism executed must degrade to a miss.'
+ );
+ } finally {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
+ /**
+ * A torn file that COMPILES but fails while linking — e.g. a class whose parent is not
+ * written yet during a file-by-file upgrade — must also stay retryable. PHP records a
+ * plain include's path in get_included_files() after compilation but BEFORE the link
+ * error, so a guard that skips any already-included path would poison the completed file
+ * for the rest of the request. This test fails against such a guard and passes when the
+ * handler tracks only the files it has executed cleanly.
+ *
+ * @testdox the registered handler loads a link-failed class file once its dependency exists.
+ */
+ public function test_registered_handler_recovers_after_a_link_failed_file_is_completed(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproLink' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ wp_mkdir_p( $dir );
+ // Syntactically valid, but extends a sibling class whose file is not written yet:
+ // the include compiles, then throws Error: Class "...Base" not found while linking.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget extends Base {}\n" );
+ clearstatcache( true, $file );
+
+ $handler( $class );
+ $this->assertFalse( class_exists( $class, false ), 'Precondition: link-failed file must degrade to a miss.' );
+ $this->assertTrue(
+ in_array( realpath( $file ), get_included_files(), true ),
+ 'Precondition: a plain include records the link-failed path, which a get_included_files() guard would skip on retry.'
+ );
+
+ // The upgrade finishes: the dependency lands and the class file is now self-contained.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {}\n" );
+ clearstatcache( true, $file );
+
+ $handler( $class );
+ $this->assertTrue(
+ class_exists( $class, false ),
+ 'Handler must load the class once the dependency exists — a link-failed attempt must not poison the retry.'
+ );
+ } finally {
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
+ /**
+ * An include that fails to OPEN — the resolved file vanished or turned unreadable
+ * between findFile()/realpath() and the include, e.g. the upgrader replacing the old
+ * plugin directory — emits a warning and returns false instead of throwing. No
+ * Throwable reaches the handler's catch block in production, so only the include's
+ * return value distinguishes this failure from a clean load; recording the path as
+ * loaded would poison every retry after the upgrade restores the file. Reproduced
+ * deterministically with a DIRECTORY at the class-file path: file_exists() (Composer's
+ * findFile() predicate) and realpath() both accept it while the include's open fails.
+ * The temporary error handler keeps the warning a warning, as in production — this
+ * suite's convertWarningsToExceptions would otherwise turn it into a caught Throwable
+ * and mask the false-return path under test.
+ *
+ * @testdox the registered handler retries a class file whose include failed to open.
+ */
+ public function test_registered_handler_recovers_after_a_failed_open(): void {
+ $handler = Autoloader::register_woocommerce_psr4_fallback();
+ $this->assertInstanceOf( \Closure::class, $handler, 'Bootstrap must register a handler.' );
+
+ $suffix = 'ReproOpen' . str_replace( '.', '', uniqid( '', true ) );
+ $dir = dirname( WC_PLUGIN_FILE ) . '/src/' . $suffix;
+ $file = $dir . '/Widget.php';
+ $class = 'Automattic\\WooCommerce\\' . $suffix . '\\Widget';
+
+ try {
+ // A directory where the class file belongs: the failed-open shape, minus the race.
+ wp_mkdir_p( $file );
+
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Keeps the include warning a warning, as in production; PHPUnit would convert it to an exception and mask the path under test.
+ set_error_handler(
+ static function () {
+ return true;
+ },
+ E_WARNING
+ );
+ try {
+ $handler( $class );
+ } finally {
+ restore_error_handler();
+ }
+ $this->assertFalse( class_exists( $class, false ), 'Precondition: a failed-open include must degrade to a miss.' );
+
+ // The upgrade completes: the path is now a regular, self-contained class file.
+ rmdir( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture; WP_Filesystem adds no value here.
+ file_put_contents( $file, "<?php\nnamespace Automattic\\WooCommerce\\{$suffix};\nclass Widget {}\n" );
+ clearstatcache( true, $file );
+
+ $handler( $class );
+ $this->assertTrue(
+ class_exists( $class, false ),
+ 'Handler must load the class once the file is restored — a failed-open include must not be recorded as loaded.'
+ );
+ } finally {
+ if ( is_dir( $file ) ) {
+ rmdir( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ if ( file_exists( $file ) ) {
+ wp_delete_file( $file );
+ }
+ if ( is_dir( $dir ) ) {
+ rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
+ }
+ }
+ }
+
/**
* Registration is idempotent: repeated calls return the same handler and never
* stack duplicate autoloaders on the SPL stack.