fix: atomic Divi check lock, failed-check notice, test fixes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 02:45:50 +02:00
co-authored by Claude Opus 5.5
parent 6113e5ad5a
commit b441febd81
5 changed files with 132 additions and 21 deletions
@@ -7,6 +7,8 @@ namespace LIT\MCP\Integrations\Divi\Release;
use Closure;
use LIT\MCP\Admin\DiviTab;
use LIT\MCP\Admin\SettingsPage;
use LIT\MCP\Log;
use Throwable;
use WP_REST_Response;
defined('ABSPATH') || exit;
@@ -15,8 +17,12 @@ defined('ABSPATH') || exit;
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
* One check at a time: the option LOCK (add_option is atomic); a lock older than LOCK_TTL is a dead check's and taken
* over. Answers 303 back to the tab.
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
* to the tab.
*/
final class DiviCheckRoute
{
@@ -59,6 +65,9 @@ final class DiviCheckRoute
($this->reports ?? new CheckReportStore())->save($report);
ReleaseGate::forget();
$notice = 'divi_checked';
} catch (Throwable $e) {
Log::failure('divi check', $e);
$notice = 'divi_check_failed';
} finally {
delete_option(self::LOCK);
}
@@ -73,16 +82,44 @@ final class DiviCheckRoute
private static function lock(): bool
{
if (add_option(self::LOCK, time(), '', false)) {
global $wpdb;
if (self::insertLock($wpdb, time())) {
return true;
}
$since = (int) get_option(self::LOCK, 0);
if ($since > 0 && time() - $since < self::LOCK_TTL) {
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
$since = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
self::LOCK
));
if ($since === null) {
// The lock was released between our failed insert and this read; try once more.
return self::insertLock($wpdb, time());
}
if (time() - (int) $since < self::LOCK_TTL) {
return false;
}
delete_option(self::LOCK);
return (bool) add_option(self::LOCK, time(), '', false);
$taken = $wpdb->query($wpdb->prepare(
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
(string) time(),
self::LOCK,
$since
));
return (int) $taken === 1;
}
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
private static function insertLock(\wpdb $wpdb, int $now): bool
{
$inserted = $wpdb->query($wpdb->prepare(
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
self::LOCK,
(string) $now
));
return (int) $inserted === 1;
}
}