From 2d59d13f771ef6d0b360c1bd87d2487ee1c417a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:01:51 +0300 Subject: [PATCH 1/4] chore(deps-dev): bump phpstan/phpstan from 2.2.14 to 2.2.15 (#1389) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.14 to 2.2.15. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.15 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index aef72b51..3139c02e 100644 --- a/composer.lock +++ b/composer.lock @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.14", + "version": "2.2.15", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", - "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", + "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", "shasum": "" }, "require": { @@ -1126,7 +1126,7 @@ "type": "github" } ], - "time": "2026-09-12T21:39:33+00:00" + "time": "2026-09-23T12:23:07+00:00" }, { "name": "phpunit/php-code-coverage", From d223dfefdeaf22ea9ea04b4593b5197c9d47738f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:01:28 +0300 Subject: [PATCH 2/4] chore(deps-dev): bump phpstan/phpstan from 2.2.15 to 2.2.16 (#1391) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.15 to 2.2.16. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.16 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 3139c02e..90dc57d0 100644 --- a/composer.lock +++ b/composer.lock @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.15", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", - "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -1126,7 +1126,7 @@ "type": "github" } ], - "time": "2026-09-23T12:23:07+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", From 0c49f5c3b85269f3a0006f4949b0293d9b59209c Mon Sep 17 00:00:00 2001 From: Alexia-Soare <108459992+Alexia-Soare@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:01:34 +0300 Subject: [PATCH 3/4] fix: survive a failed action that another process already handled (#1381) * fix: survive a failed action that another process already handled Action Scheduler's `mark_failure()` throws "Unidentified action" when its UPDATE changes no row. That happens when another process deleted the action, and also when an overlapping cleaner already marked it failed: WP-Cron's queue run takes no lock, only the async runner does. Two callers let that exception escape and end the request with a fatal: the queue cleaner loop and the runner's action error path. The bundled copy is patched at `composer install` so both callers skip that action and continue, the same guard `delete_actions()` already uses. `composer-exit-on-patch-failure` makes a future Action Scheduler bump fail loudly instead of dropping the fix. Upstream 4.2.0 still throws; see woocommerce/action-scheduler#970. Refs: #1369 Co-Authored-By: Claude Fable 5.1 * test: keep WordPress update checks out of AJAX tests for any file order The framework snapshots hooks at the first test of the run and restores that snapshot after every test. WP_Ajax_UnitTestCase removes the `_maybe_update_*` admin_init hooks once per class, which only holds when an AJAX class runs first. A test file that sorts before test-ajax.php put the hooks back into the snapshot, so every later AJAX test called api.wordpress.org and failed on the response. Remove the hooks in the bootstrap so the order of test files does not matter. Co-Authored-By: Claude Fable 5.1 * fix: let real database errors surface when marking an action failed The guard caught every Exception, so a database error inside `mark_failure()` was swallowed together with the race it targets. The store throws the same InvalidArgumentException for both; only `$wpdb->last_error` tells them apart. Catch that type only and rethrow when the database reported an error. Tests: one-shot guard in the query interceptor, so an injected UPDATE cannot re-enter it; a pattern that accepts quoted ids; a test that breaks the UPDATE and expects the exception to surface. Co-Authored-By: Claude Fable 5.1 * build: refresh composer.lock content hash composer.json changed after the lock was written, so `composer validate` failed and every install warned that the lock file was stale. Co-Authored-By: Claude Fable 5.1 * test: restore the previous suppress_errors() setting Co-Authored-By: Claude Fable 5.1 * test: name the SQL predicate; note why last_error is trustworthy in the guard Co-Authored-By: Claude Fable 5.1 * test: seed through the store API; cover the runner path with a deleted action Co-Authored-By: Claude Fable 5.1 * fix: tolerate zero changed rows inside the store instead of at each caller `wpdb::update()` returns false on a database error and 0 when no row changed. `ActionScheduler_DBStore::mark_failure()` now throws only on false, so the race (deleted or already failed by another process) is handled once for every caller and no caller reads `$wpdb->last_error`. One hunk replaces the two caller guards. Co-Authored-By: Claude Fable 5.1 * test: intercept only the UPDATE that sets status to failed Co-Authored-By: Claude Fable 5.1 * fix: replace the vendor patch with a store class of our own Patching the bundled Action Scheduler needed a Composer plugin, a patch file kept in the repo, and a re-roll on every dependency bump. Action Scheduler resolves its store through `action_scheduler_store_class`. Visualizer now answers that filter with a subclass of the database store that tolerates zero changed rows in `mark_failure()`, and still throws when the UPDATE itself failed. The library stays untouched, so a version bump needs no work, and the fix applies to whichever copy of Action Scheduler is loaded. Other stores, including another plugin's, are left alone. Refs: #1369 Co-Authored-By: Claude Opus 5 (1M context) * test: cover the store filter priority and tidy the mark-failure tests Co-Authored-By: Claude Opus 5.5 (1M context) * fix: tell a database error from zero changed rows by the UPDATE result Co-Authored-By: Claude Opus 5.5 (1M context) * fix: format the action ID with %d in the failure message Co-Authored-By: Claude Opus 5.5 (1M context) * test: escape the status value in the UPDATE matcher Co-Authored-By: Claude Opus 5.5 (1M context) * docs: say which parts of the UPDATE matcher accept quotes Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Fable 5.1 --- classes/Visualizer/ActionScheduler/Store.php | 49 ++++ index.php | 17 ++ phpstan.neon | 1 + tests/bootstrap.php | 8 + tests/test-action-scheduler-mark-failure.php | 241 +++++++++++++++++++ 5 files changed, 316 insertions(+) create mode 100644 classes/Visualizer/ActionScheduler/Store.php create mode 100644 tests/test-action-scheduler-mark-failure.php diff --git a/classes/Visualizer/ActionScheduler/Store.php b/classes/Visualizer/ActionScheduler/Store.php new file mode 100644 index 00000000..f080dbf8 --- /dev/null +++ b/classes/Visualizer/ActionScheduler/Store.php @@ -0,0 +1,49 @@ +update( + $wpdb->actionscheduler_actions, + array( 'status' => self::STATUS_FAILED ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + if ( false === $updated ) { + /* translators: %d is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unable to mark action %d as failed.', 'visualizer' ), $action_id ) ); + } + } +} diff --git a/index.php b/index.php index 92530b82..05b0725d 100644 --- a/index.php +++ b/index.php @@ -159,6 +159,9 @@ function () { require_once $action_scheduler_file; } + // After Action Scheduler's own data controller, which sets the class at 100. + add_filter( 'action_scheduler_store_class', 'visualizer_action_scheduler_store_class', 200 ); + add_filter( 'themeisle_sdk_products', 'visualizer_register_sdk', 10, 1 ); add_filter( 'pirate_parrot_log', 'visualizer_register_parrot', 10, 1 ); add_filter( @@ -254,6 +257,20 @@ function visualizer_can_use_action_scheduler() { return isset( $wpdb ) && is_callable( array( $wpdb, 'db_server_info' ) ); } +/** + * Use a store that survives a lost race when marking an action failed. + * + * Only replaces Action Scheduler's own database store. Another plugin's store + * and the legacy post store, which does not have the problem, are left alone. + * + * @param string $class_name Store class Action Scheduler resolved. + * + * @return string + */ +function visualizer_action_scheduler_store_class( $class_name ) { + return 'ActionScheduler_DBStore' === $class_name ? 'Visualizer_ActionScheduler_Store' : $class_name; +} + /** * Registers with the SDK * diff --git a/phpstan.neon b/phpstan.neon index 021d0259..477bdeff 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,6 +12,7 @@ parameters: - %currentWorkingDirectory%/vendor/neitanod/forceutf8 - %currentWorkingDirectory%/vendor/openspout/openspout - %currentWorkingDirectory%/vendor/codeinwp/themeisle-sdk + - %currentWorkingDirectory%/vendor/woocommerce/action-scheduler excludePaths: - classes/Visualizer/Gutenberg/build (?) - classes/Visualizer/GutenChartBuilder/build (?) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 230071c9..ef7f6d38 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -32,6 +32,14 @@ function _manually_load_plugin() { tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; + +// The framework snapshots hooks at the first test and restores that snapshot +// after every test. WP_Ajax_UnitTestCase removes these once per class, which +// only holds when an AJAX class runs first. Remove them here so no test file +// order makes AJAX tests call api.wordpress.org. +remove_action( 'admin_init', '_maybe_update_core' ); +remove_action( 'admin_init', '_maybe_update_plugins' ); +remove_action( 'admin_init', '_maybe_update_themes' ); activate_plugin( 'visualizer/index.php' ); global $current_user; $current_user = new WP_User( 1 ); diff --git a/tests/test-action-scheduler-mark-failure.php b/tests/test-action-scheduler-mark-failure.php new file mode 100644 index 00000000..8b40e8b8 --- /dev/null +++ b/tests/test-action-scheduler-mark-failure.php @@ -0,0 +1,241 @@ + + */ + private $filters_to_remove = array(); + + /** + * Skip when Action Scheduler is not loaded. + */ + public function set_up() { + parent::set_up(); + + if ( ! class_exists( 'ActionScheduler_DBStore' ) ) { + $this->markTestSkipped( 'Action Scheduler is not loaded.' ); + } + + $this->store = new Visualizer_ActionScheduler_Store(); + $this->store->init(); + } + + /** + * Remove the query filters even when a test throws. + */ + public function tear_down() { + foreach ( $this->filters_to_remove as $filter ) { + remove_filter( 'query', $filter ); + } + $this->filters_to_remove = array(); + parent::tear_down(); + } + + /** + * Save an action, then make it a stale in-progress one (last attempt two hours ago). + * + * @return int Action id. + */ + private function seed_stale_running_action() { + global $wpdb; + $action_id = $this->store->save_action( new ActionScheduler_Action( 'visualizer_schedule_refresh_db', array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-2 hours' ) ) ) ); + $gmt = gmdate( 'Y-m-d H:i:s', time() - 2 * HOUR_IN_SECONDS ); + $wpdb->update( + $wpdb->actionscheduler_actions, + array( + 'status' => ActionScheduler_Store::STATUS_RUNNING, + 'last_attempt_gmt' => $gmt, + 'last_attempt_local' => $gmt, + ), + array( 'action_id' => $action_id ) + ); + return (int) $action_id; + } + + /** + * Status column of one action, or null when the row is gone. + * + * @param int $action_id Action id. + * @return string|null + */ + private function status_of( $action_id ) { + global $wpdb; + return $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id = %d", $action_id ) ); + } + + /** + * Run `$intercept` once, on the UPDATE that marks `$action_id` failed, and + * use its return value as the SQL to execute. + * + * @param int $action_id Action whose UPDATE is intercepted. + * @param callable(string): string $intercept Receives the SQL, returns the SQL to run. + */ + private function intercept_mark_failure_update( $action_id, callable $intercept ) { + global $wpdb; + $table = $wpdb->actionscheduler_actions; + $done = false; + // Queries issued inside $intercept re-enter this filter: run it once only. + $filter = function ( $sql ) use ( $action_id, $table, $intercept, &$done ) { + if ( $done || ! $this->is_mark_failed_update( $sql, $table, $action_id ) ) { + return $sql; + } + $done = true; + return $intercept( $sql ); + }; + add_filter( 'query', $filter ); + $this->filters_to_remove[] = $filter; + } + + /** + * Whether `$sql` is the UPDATE that marks `$action_id` in `$table` failed. + * Matches the SQL `wpdb::update()` builds, with or without backticks, and a bare or quoted action id. + * + * @param string $sql SQL about to run. + * @param string $table Actions table name. + * @param int $action_id Action id. + * @return bool + */ + private function is_mark_failed_update( $sql, $table, $action_id ) { + if ( 0 !== stripos( ltrim( $sql ), 'UPDATE' ) || false === strpos( $sql, $table ) ) { + return false; + } + if ( ! preg_match( '/status`?\s*=\s*\'' . preg_quote( ActionScheduler_Store::STATUS_FAILED, '/' ) . '\'/', $sql ) ) { + return false; + } + return preg_match( '/action_id`?\s*=\s*\'?(\d+)/', $sql, $m ) && (int) $m[1] === $action_id; + } + + /** + * Visualizer replaces Action Scheduler's own database store, and nothing else. + */ + public function test_filter_replaces_only_the_default_database_store() { + $this->assertSame( 'Visualizer_ActionScheduler_Store', visualizer_action_scheduler_store_class( 'ActionScheduler_DBStore' ) ); + $this->assertSame( 'Another_Plugin_Store', visualizer_action_scheduler_store_class( 'Another_Plugin_Store' ) ); + $this->assertSame( 'ActionScheduler_HybridStore', visualizer_action_scheduler_store_class( 'ActionScheduler_HybridStore' ) ); + } + + /** + * The filter runs after Action Scheduler's data controller, so its class wins. + */ + public function test_filter_runs_after_the_data_controller() { + update_option( 'action_scheduler_migration_status', 'complete' ); + + $this->assertSame( 'Visualizer_ActionScheduler_Store', apply_filters( 'action_scheduler_store_class', ActionScheduler_Store::DEFAULT_CLASS ) ); + } + + /** + * Another process deleted the action: nothing left to mark. + */ + public function test_mark_failure_tolerates_a_deleted_action() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) ); + + $this->store->mark_failure( $action_id ); + + $this->assertNull( $this->status_of( $action_id ) ); + } + + /** + * An overlapping cleaner already marked it failed: the UPDATE changes nothing. + */ + public function test_mark_failure_tolerates_an_already_failed_action() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + $wpdb->update( $wpdb->actionscheduler_actions, array( 'status' => ActionScheduler_Store::STATUS_FAILED ), array( 'action_id' => $action_id ) ); + + $this->store->mark_failure( $action_id ); + + $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $action_id ) ); + } + + /** + * A real database error still surfaces. + */ + public function test_mark_failure_still_throws_on_a_database_error() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + + // Break the UPDATE itself: the store gets `false`, not zero rows. + $this->intercept_mark_failure_update( + $action_id, + static function ( $sql ) use ( $wpdb ) { + return str_replace( $wpdb->actionscheduler_actions, 'no_such_table', $sql ); + } + ); + $suppressed = $wpdb->suppress_errors( true ); + + $this->expectException( InvalidArgumentException::class ); + try { + $this->store->mark_failure( $action_id ); + } finally { + $wpdb->suppress_errors( $suppressed ); + } + } + + /** + * Queue cleanup keeps going when an action vanishes between its query and its update. + */ + public function test_mark_failures_continues_past_an_action_deleted_by_another_process() { + global $wpdb; + $vanishing = $this->seed_stale_running_action(); + $survivor = $this->seed_stale_running_action(); + + $this->intercept_mark_failure_update( + $vanishing, + static function ( $sql ) use ( $wpdb, $vanishing ) { + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $vanishing ) ); + return $sql; + } + ); + + ( new ActionScheduler_QueueCleaner( $this->store ) )->mark_failures( 60 ); + + $this->assertNull( $this->status_of( $vanishing ), 'the concurrently deleted action stays gone' ); + $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $survivor ), 'cleanup continues and marks the remaining stale action failed' ); + } + + /** + * Runner path (the trace in upstream #970): the action is deleted while it + * runs, then it throws, and the runner marks it failed. + */ + public function test_process_action_survives_marking_a_deleted_action() { + global $wpdb; + $hook = 'visualizer_test_throwing_action'; + $action_id = $this->store->save_action( new ActionScheduler_Action( $hook, array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 minute' ) ) ) ); + + add_action( + $hook, + static function () use ( $wpdb, $action_id ) { + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) ); + throw new RuntimeException( 'refresh failed' ); + } + ); + + ( new ActionScheduler_QueueRunner( $this->store ) )->process_action( $action_id, 'test' ); + + $this->assertNull( $this->status_of( $action_id ), 'the deleted action stays gone and the run survives' ); + } +} From 0717f855911af896626f038eca6c2865f25abe06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:01:59 +0300 Subject: [PATCH 4/4] chore(deps): bump codeinwp/themeisle-sdk from 3.3.65 to 3.3.66 (#1392) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.65 to 3.3.66. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.65...v3.3.66) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.66 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 90dc57d0..85af6db8 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.65", + "version": "3.3.66", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" + "reference": "27e095d8357e0594cd5769222af1258538832e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/27e095d8357e0594cd5769222af1258538832e75", + "reference": "27e095d8357e0594cd5769222af1258538832e75", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.66" }, - "time": "2026-09-29T09:35:47+00:00" + "time": "2026-10-07T08:46:25+00:00" }, { "name": "neitanod/forceutf8",