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.
*/