Commit b133a46925d for woocommerce

commit b133a46925db701a51fe41d8ecf0d2a68697c244
Author: Vasily Belolapotkov <vasily.belolapotkov@automattic.com>
Date:   Fri Sep 11 15:57:48 2026 +0200

    Bootstrap Action Scheduler during uninstall so scheduled actions are removed (#68527)

    Bootstrap Action Scheduler on uninstall so pending actions are removed

    - Require the bundled Action Scheduler in uninstall.php when no other plugin has loaded it, so the as_unschedule_all_actions() block actually runs
    - Unhook the queue runner's shutdown dispatch in a finally block, since WordPress deletes the plugin files later in the same request and the hook would autoload classes that no longer exist
    - Cancel the *_wrapper hooks that the wrapper refactor left out of the cleanup list
    - Log bootstrap failures via error_log() instead of swallowing them

diff --git a/plugins/woocommerce/changelog/fix-60415-uninstall-unschedule-actions b/plugins/woocommerce/changelog/fix-60415-uninstall-unschedule-actions
new file mode 100644
index 00000000000..de5e8d5d72f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-60415-uninstall-unschedule-actions
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Ensure WooCommerce scheduled actions are removed on uninstall by bootstrapping Action Scheduler when no other plugin has loaded it.
diff --git a/plugins/woocommerce/uninstall.php b/plugins/woocommerce/uninstall.php
index eefb5287b8e..cf16e329534 100644
--- a/plugins/woocommerce/uninstall.php
+++ b/plugins/woocommerce/uninstall.php
@@ -27,6 +27,45 @@ wp_clear_scheduled_hook( 'wc_admin_daily' );
 wp_clear_scheduled_hook( 'generate_category_lookup_table' );
 wp_clear_scheduled_hook( 'wc_admin_unsnooze_admin_notes' );

+/*
+ * WordPress deactivates a plugin before running its uninstall.php, so `plugins_loaded` has already
+ * fired without WooCommerce. When WooCommerce is the only plugin bundling Action Scheduler, nothing
+ * has loaded the library and the cleanup below would be skipped, leaving every pending action behind.
+ *
+ * Requiring the bundled bootstrap is enough to fully initialize it: the file self-initializes once
+ * `plugins_loaded` has fired (the same path it uses when loaded from a theme).
+ *
+ * That initialization fires `action_scheduler_init`, and WordPress does not deactivate WooCommerce
+ * extensions before deleting WooCommerce, so a still-active extension's callback runs here without
+ * WooCommerce loaded. Anything it throws is caught: leaving actions behind is the bug we are fixing,
+ * but aborting uninstall_plugin() would also stop WordPress from deleting the plugin's files.
+ */
+if ( ! class_exists( 'ActionScheduler', false ) ) {
+	$wc_action_scheduler_bootstrap = __DIR__ . '/packages/action-scheduler/action-scheduler.php';
+
+	if ( is_readable( $wc_action_scheduler_bootstrap ) ) {
+		try {
+			require_once $wc_action_scheduler_bootstrap;
+		} catch ( Throwable $e ) {
+			// Leave a trace; whether the cleanup below still runs depends on how far initialization got.
+			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- No WooCommerce logger during uninstall.
+			error_log( 'WooCommerce: Action Scheduler threw while loading during uninstall: ' . $e->getMessage() );
+		} finally {
+			/*
+			 * WordPress deletes this plugin's files later in the same request, so the queue runner's
+			 * shutdown hook would autoload Action Scheduler classes that no longer exist and fatal.
+			 * The hook is registered before `action_scheduler_init` fires, so a throw from a callback on
+			 * that action still leaves it attached; is_initialized() is set at the same point.
+			 */
+			if ( class_exists( 'ActionScheduler', false ) && ActionScheduler::is_initialized() ) {
+				ActionScheduler::runner()->unhook_dispatch_async_request();
+			}
+		}
+	}
+
+	unset( $wc_action_scheduler_bootstrap );
+}
+
 if ( class_exists( ActionScheduler::class ) && ActionScheduler::is_initialized() && function_exists( 'as_unschedule_all_actions' ) ) {
 	as_unschedule_all_actions( 'woocommerce_scheduled_sales' );
 	as_unschedule_all_actions( 'woocommerce_cancel_unpaid_orders' );
@@ -39,6 +78,12 @@ if ( class_exists( ActionScheduler::class ) && ActionScheduler::is_initialized()
 	as_unschedule_all_actions( 'wc_admin_daily' );
 	as_unschedule_all_actions( 'generate_category_lookup_table' );
 	as_unschedule_all_actions( 'wc_admin_unsnooze_admin_notes' );
+
+	// WooCommerce::register_recurring_actions() schedules these four under wrapper hooks.
+	as_unschedule_all_actions( 'woocommerce_tracker_send_event_wrapper' );
+	as_unschedule_all_actions( 'woocommerce_cleanup_rate_limits_wrapper' );
+	as_unschedule_all_actions( 'wc_admin_daily_wrapper' );
+	as_unschedule_all_actions( 'generate_category_lookup_table_wrapper' );
 }

 /*