Commit 5d2192e8700 for woocommerce

commit 5d2192e87001cfeae76ee23ed509bf5a3328f08e
Author: Jorge A. Torres <jorge.torres@automattic.com>
Date:   Thu Feb 26 09:49:13 2026 +0000

    Improve FTP handling in logging filesystem code (#63298)

diff --git a/plugins/woocommerce/changelog/fix-58985 b/plugins/woocommerce/changelog/fix-58985
new file mode 100644
index 00000000000..0e037564947
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-58985
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Prevent fatal error when FTP filesystem connection fails during logging operations.
diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
index 647eb7fe39c..b175f1ff0d1 100644
--- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php
@@ -12,6 +12,17 @@ use WP_Filesystem_Base;
  * FilesystemUtil class.
  */
 class FilesystemUtil {
+
+	/**
+	 * Transient key for tracking FTP filesystem initialization failures.
+	 */
+	private const FTP_INIT_FAILURE_TRANSIENT = 'wc_ftp_filesystem_init_failed';
+
+	/**
+	 * Cooldown period in minutes before retrying a failed FTP connection.
+	 */
+	private const FTP_INIT_COOLDOWN_MINUTES = 2;
+
 	/**
 	 * Wrapper to retrieve the class instance contained in the $wp_filesystem global, after initializing if necessary.
 	 *
@@ -21,12 +32,10 @@ class FilesystemUtil {
 	public static function get_wp_filesystem(): WP_Filesystem_Base {
 		global $wp_filesystem;

-		if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
-			$initialized = self::initialize_wp_filesystem();
+		$initialized = ( $wp_filesystem instanceof WP_Filesystem_Base ) || self::initialize_wp_filesystem();

-			if ( false === $initialized ) {
-				throw new Exception( 'The WordPress filesystem could not be initialized.' );
-			}
+		if ( ! $initialized || ! self::is_usable_ftp_filesystem( $wp_filesystem ) ) {
+			throw new Exception( 'The WordPress filesystem could not be initialized.' );
 		}

 		return $wp_filesystem;
@@ -114,17 +123,57 @@ class FilesystemUtil {
 		if ( 'direct' === $method ) {
 			$initialized = WP_Filesystem();
 		} elseif ( false !== $method ) {
+			if ( get_transient( self::FTP_INIT_FAILURE_TRANSIENT ) ) {
+				return false;
+			}
+
 			// See https://core.trac.wordpress.org/changeset/56341.
 			ob_start();
 			$credentials = request_filesystem_credentials( '' );
 			ob_end_clean();

 			$initialized = $credentials && WP_Filesystem( $credentials );
+
+			if ( ! $initialized ) {
+				// A fixed cooldown is used instead of exponential backoff since this handles a non-critical
+				// edge case (broken FTP filesystem during logging) that most sites will never encounter.
+				set_transient( self::FTP_INIT_FAILURE_TRANSIENT, true, self::FTP_INIT_COOLDOWN_MINUTES * MINUTE_IN_SECONDS );
+				error_log( sprintf( 'WooCommerce: FTP filesystem connection failed. Please check your FTP credentials. Retrying in %d minutes.', self::FTP_INIT_COOLDOWN_MINUTES ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+			} else {
+				delete_transient( self::FTP_INIT_FAILURE_TRANSIENT );
+			}
 		}

 		return is_null( $initialized ) ? false : $initialized;
 	}

+	/**
+	 * Check if an FTP-based filesystem instance is usable.
+	 *
+	 * Checks both the connection resource and the error state. The connection
+	 * resource can be null if PHP's max execution time interrupted ftp_connect()
+	 * before it completed, leaving the instance in a broken state without errors.
+	 *
+	 * @param WP_Filesystem_Base $wp_filesystem The filesystem instance to check.
+	 * @return bool False if FTP-based and unusable, true otherwise.
+	 */
+	private static function is_usable_ftp_filesystem( WP_Filesystem_Base $wp_filesystem ): bool {
+		$has_broken_state = false;
+		$has_errors       = false;
+
+		if ( 'ftpext' === $wp_filesystem->method ) {
+			$has_broken_state = empty( $wp_filesystem->link );
+			$has_errors       = is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors();
+		}
+
+		if ( 'ftpsockets' === $wp_filesystem->method ) {
+			$has_broken_state = empty( $wp_filesystem->ftp );
+			$has_errors       = is_wp_error( $wp_filesystem->errors ) && $wp_filesystem->errors->has_errors();
+		}
+
+		return ! $has_broken_state && ! $has_errors;
+	}
+
 	/**
 	 * Validate that a file path is a valid upload path.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
index 4a8adc1e67a..a9e389d6cf5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Utilities/FilesystemUtilTest.php
@@ -20,6 +20,9 @@ class FilesystemUtilTest extends WC_Unit_Test_Case {
 	public static function setUpBeforeClass(): void {
 		parent::setUpBeforeClass();

+		if ( ! class_exists( 'WP_Filesystem_Base' ) ) {
+			require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
+		}
 		unset( $GLOBALS['wp_filesystem'] );
 	}

@@ -62,6 +65,37 @@ class FilesystemUtilTest extends WC_Unit_Test_Case {
 		remove_filter( 'filesystem_method', $callback );
 	}

+	/**
+	 * @testdox Check that get_wp_filesystem validates FTP filesystem instances.
+	 *
+	 * @testWith [true, true, true]
+	 *           [false, false, true]
+	 *           [false, true, false]
+	 *
+	 * @param bool $has_errors   Whether the mock should have connection errors.
+	 * @param bool $has_link     Whether the mock should have a connection link.
+	 * @param bool $should_throw Whether get_wp_filesystem should throw.
+	 */
+	public function test_get_wp_filesystem_validates_ftp( bool $has_errors, bool $has_link, bool $should_throw ): void {
+		global $wp_filesystem;
+
+		$mock_wp_filesystem         = $this->createMock( WP_Filesystem_Base::class );
+		$mock_wp_filesystem->method = 'ftpext';
+		$mock_wp_filesystem->errors = $has_errors ? new \WP_Error( 'connect', 'Failed to connect to FTP Server' ) : new \WP_Error();
+		$mock_wp_filesystem->link   = $has_link ? true : null;
+		$wp_filesystem              = $mock_wp_filesystem; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+		if ( $should_throw ) {
+			$this->expectException( 'Exception' );
+		}
+
+		$result = FilesystemUtil::get_wp_filesystem();
+
+		if ( ! $should_throw ) {
+			$this->assertSame( $mock_wp_filesystem, $result );
+		}
+	}
+
 	/**
 	 * @testdox 'get_wp_filesystem_method_or_direct' returns 'direct' if no FS_METHOD constant, not 'ftp_credentials' option and not FTP_HOST constant exist.
 	 */