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.