| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
if (!defined('ABSPATH')) { |
| 5 |
exit; |
| 6 |
} |
| 7 |
|
| 8 |
/** |
| 9 |
* The synchronizer lock protocol: mint an owner id, acquire it, break a lock |
| 10 |
* whose holder is gone, and release it -- including when the holder dies |
| 11 |
* without unwinding. |
| 12 |
* |
| 13 |
* Storage of the owner records themselves belongs to |
| 14 |
* ABJ_404_Solution_LockOwnerStore; nothing in this class touches the options |
| 15 |
* table or the filesystem directly. |
| 16 |
*/ |
| 17 |
class ABJ_404_Solution_SynchronizationUtils { |
| 18 |
|
| 19 |
/** Absolute ceiling, in seconds, on how long any lock may look legitimately |
| 20 |
* held before a later acquirer breaks it. |
| 21 |
* |
| 22 |
* The stale-lock threshold is derived from max_execution_time (a request |
| 23 |
* cannot legitimately outlive it), but that value is host-controlled and |
| 24 |
* unbounded. westcoat.kinsta.cloud reported max_execution_time=43200, which |
| 25 |
* the old "* 2" heuristic turned into a 24-hour window: a lock leaked by a |
| 26 |
* fatal on 2026-07-11 04:36 was not broken until 2026-07-12 04:40, after |
| 27 |
* 86615 seconds, and the site served a 4.2.0 schema to 4.3.1 code the whole |
| 28 |
* time. No critical section in this plugin legitimately runs for minutes, so |
| 29 |
* the derived value is capped here regardless of what the host allows. |
| 30 |
* @var int */ |
| 31 |
const LOCK_STALE_CEILING_SECONDS = 300; |
| 32 |
|
| 33 |
/** Stale-lock threshold used when max_execution_time reports no limit |
| 34 |
* (0 / empty, as under CLI, WP-CLI and many cron contexts). |
| 35 |
* @var int */ |
| 36 |
const LOCK_STALE_FALLBACK_SECONDS = 60; |
| 37 |
|
| 38 |
/** Locks acquired by THIS instance during THIS request that have not been |
| 39 |
* released yet, as internal key => unique ID. |
| 40 |
* |
| 41 |
* A synchronizer lock is a plain owner record in an option row or a file; |
| 42 |
* nothing in the storage layer knows the holder died. Callers all release in |
| 43 |
* a finally block, which covers exceptions but NOT the failure modes that |
| 44 |
* actually leak: E_ERROR, OOM, and request timeouts unwind straight past |
| 45 |
* finally. Tracking held locks here lets releaseLocksLeakedByThisRequest() |
| 46 |
* clean up from a shutdown function, which PHP does still run after a fatal. |
| 47 |
* @var array<string, string> */ |
| 48 |
private $locksHeldThisRequest = array(); |
| 49 |
|
| 50 |
/** Whether the shutdown hook that releases leaked locks is registered. |
| 51 |
* register_shutdown_function() is additive and cannot be undone, so it is |
| 52 |
* wired at most once per instance and made idempotent instead. |
| 53 |
* @var bool */ |
| 54 |
private $shutdownReleaseRegistered = false; |
| 55 |
|
| 56 |
/** @var ABJ_404_Solution_LockOwnerStore */ |
| 57 |
private $ownerStore; |
| 58 |
|
| 59 |
/** @var self|null */ |
| 60 |
private static $instance = null; |
| 61 |
|
| 62 |
/** |
| 63 |
* Test seam: install or clear the cached singleton instance without |
| 64 |
* private-field reflection. Pass null to reset between tests; pass a |
| 65 |
* configured instance (or double) to install it. Mirrors the setInstance() |
| 66 |
* contract on DataAccess / PluginLogic (M105 singleton-reset seam). |
| 67 |
* |
| 68 |
* @param self|null $instance |
| 69 |
* @return void |
| 70 |
*/ |
| 71 |
public static function setInstance($instance) { |
| 72 |
self::$instance = $instance; |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Test seam: clear all cached static state (the singleton instance and the |
| 77 |
* owner store's file-vs-options lock-mode latch) without private-field |
| 78 |
* reflection. |
| 79 |
* |
| 80 |
* @return void |
| 81 |
*/ |
| 82 |
public static function resetForTests() { |
| 83 |
self::$instance = null; |
| 84 |
ABJ_404_Solution_LockOwnerStore::resetForTests(); |
| 85 |
} |
| 86 |
|
| 87 |
public function __construct(?ABJ_404_Solution_LockOwnerStore $ownerStore = null) { |
| 88 |
$this->ownerStore = $ownerStore !== null ? $ownerStore : new ABJ_404_Solution_LockOwnerStore(); |
| 89 |
} |
| 90 |
|
| 91 |
/** @return self */ |
| 92 |
public static function getInstance() { |
| 93 |
if (self::$instance == null) { |
| 94 |
self::$instance = new ABJ_404_Solution_SynchronizationUtils(); |
| 95 |
} |
| 96 |
|
| 97 |
return self::$instance; |
| 98 |
} |
| 99 |
|
| 100 |
/** The owner-record storage this lock protocol reads and writes through. |
| 101 |
* |
| 102 |
* Exposed so callers that need the storage decision itself (the |
| 103 |
* file-vs-options latch, most often in tests pinning a deterministic mode) |
| 104 |
* can reach it without this class re-publishing the store's surface. |
| 105 |
* |
| 106 |
* @return ABJ_404_Solution_LockOwnerStore |
| 107 |
*/ |
| 108 |
function ownerStore() { |
| 109 |
return $this->ownerStore; |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* @param string $keyFromUser |
| 114 |
* @return string |
| 115 |
*/ |
| 116 |
private function createInternalKey($keyFromUser) { |
| 117 |
return $this->ownerStore->createInternalKey($keyFromUser); |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* @param string $keyFromUser |
| 122 |
* @return string |
| 123 |
*/ |
| 124 |
private function createUniqueID($keyFromUser) { |
| 125 |
return abj_clock()->nowFloat() . "_" . $keyFromUser . '_' . $this->uniqidReal() . uniqid('', true); |
| 126 |
} |
| 127 |
|
| 128 |
/** Returns an empty string if the lock is not acquired. |
| 129 |
* @param string $synchronizedKeyFromUser |
| 130 |
* @return string the unique ID that was used. This is needed to release the lock. Or an empty string if |
| 131 |
* the lock wasn't acquired. |
| 132 |
*/ |
| 133 |
function synchronizerAcquireLockTry($synchronizedKeyFromUser) { |
| 134 |
$uniqueID = $this->createUniqueID($synchronizedKeyFromUser); |
| 135 |
$internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser); |
| 136 |
|
| 137 |
// don't let anyone hold the lock for too long. |
| 138 |
$this->fixAnUnforeseenIssue($synchronizedKeyFromUser); |
| 139 |
|
| 140 |
if (!$this->ownerStore->claimOwner(array( |
| 141 |
'key' => $internalSynchronizedKey, |
| 142 |
'owner' => $uniqueID, |
| 143 |
))) { |
| 144 |
// Somebody else owns it. This request wrote nothing, so it has |
| 145 |
// nothing to clean up. |
| 146 |
return ''; |
| 147 |
} |
| 148 |
|
| 149 |
// Arm the crash-safe release as the very next thing after the record |
| 150 |
// exists. The two cannot be made one operation in PHP, so a fatal in |
| 151 |
// between still leaks the record -- but that gap is now a single |
| 152 |
// statement rather than the 300ms settle sleep the old read-write- |
| 153 |
// sleep-read protocol had to hold it open for. |
| 154 |
$this->rememberHeldLock($internalSynchronizedKey, $uniqueID); |
| 155 |
|
| 156 |
return $uniqueID; |
| 157 |
} |
| 158 |
|
| 159 |
/** Remove the lock if it's been in place for too long. |
| 160 |
* @param string $synchronizedKeyFromUser |
| 161 |
* @return void |
| 162 |
*/ |
| 163 |
function fixAnUnforeseenIssue($synchronizedKeyFromUser) { |
| 164 |
$internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser); |
| 165 |
|
| 166 |
$uniqueID = $this->ownerStore->readOwner($internalSynchronizedKey); |
| 167 |
|
| 168 |
if (empty($uniqueID)) { |
| 169 |
return; |
| 170 |
} |
| 171 |
|
| 172 |
$uniqueIDInfo = explode("_", $uniqueID); |
| 173 |
|
| 174 |
$createTime = $uniqueIDInfo[0]; |
| 175 |
|
| 176 |
$timePassed = abj_clock()->nowFloat() - (float)$createTime; |
| 177 |
|
| 178 |
$maxExecutionTime = $this->staleLockThresholdSeconds(); |
| 179 |
|
| 180 |
// it should have been released by now. |
| 181 |
if ($timePassed > $maxExecutionTime) { |
| 182 |
$this->ownerStore->deleteOwner(array( |
| 183 |
'key' => $internalSynchronizedKey, |
| 184 |
'owner' => $uniqueID, |
| 185 |
)); |
| 186 |
$valueAfterDelete = $this->ownerStore->readOwner($internalSynchronizedKey); |
| 187 |
|
| 188 |
// Options storage is only proven broken when the record that is |
| 189 |
// still sitting there is the SAME one we just deleted. A different |
| 190 |
// value means another request legitimately claimed the key in the |
| 191 |
// meantime, which is the protocol working rather than the storage |
| 192 |
// failing, and latching the whole site onto file-based records over |
| 193 |
// it would be a false alarm. (deleteOwner() only removes a record |
| 194 |
// whose value the caller named, so losing that race leaves the new |
| 195 |
// owner's record untouched, which is exactly what should happen.) |
| 196 |
if ($valueAfterDelete === $uniqueID && |
| 197 |
!$this->ownerStore->isFileMode()) { |
| 198 |
$this->ownerStore->switchToFileSyncMode(); |
| 199 |
return; |
| 200 |
} |
| 201 |
|
| 202 |
$uniqueIDForDebugging = $this->createUniqueID('DEBUG_KEY'); |
| 203 |
$logger = abj_service('logging'); |
| 204 |
$logger->errorMessage("Forcibly removed synchronization after " . |
| 205 |
$timePassed . " seconds for the " . "key " . $internalSynchronizedKey . |
| 206 |
" with value: " . $uniqueID . ', value after delete: ' . $valueAfterDelete . |
| 207 |
", microtime: " . abj_clock()->nowFloat() . ", unique ID for debugging: " . |
| 208 |
$uniqueIDForDebugging . ", File sync mode: " . json_encode($this->ownerStore->isFileMode())); |
| 209 |
} |
| 210 |
} |
| 211 |
|
| 212 |
// There is deliberately no blocking acquire here. synchronizerAcquireLockWithWait() |
| 213 |
// used to sit at this spot: a `while (!claimOwner(...))` that slept half a second |
| 214 |
// between attempts, with no ceiling, no deadline and no give-up. Whether it ever |
| 215 |
// returned was entirely the storage layer's decision, and every reason a claim can |
| 216 |
// fail permanently -- a read-only replica refusing the write, a full disk, an |
| 217 |
// unwritable uploads directory, a leaked owner record younger than the stale-lock |
| 218 |
// threshold -- turned it into a request that ran until max_execution_time killed it. |
| 219 |
// It had no callers in the plugin's whole recorded history. |
| 220 |
// |
| 221 |
// Waiting is therefore the CALLER's decision, not this class's: take the lock with |
| 222 |
// synchronizerAcquireLockTry(), and on '' either skip the work (what every caller |
| 223 |
// here does, because a concurrent request is already doing it) or retry under a |
| 224 |
// deadline the caller owns and can report on. A bounded waiter may be added back if |
| 225 |
// something genuinely needs one, but it has to carry a wall-clock deadline read from |
| 226 |
// abj_clock() -- time_nanosleep() is not a clock under load -- and a return contract |
| 227 |
// that can express "the deadline passed" with the reason the claims were failing. |
| 228 |
// LockAcquireApiSurfaceTest holds that line for every acquire method on this class. |
| 229 |
|
| 230 |
/** Release the lock for a synchronized block. Should be done in a finally block. |
| 231 |
* @param string $uniqueID |
| 232 |
* @param string $synchronizedKeyFromUser |
| 233 |
* @return void |
| 234 |
* @throws Exception |
| 235 |
*/ |
| 236 |
function synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser) { |
| 237 |
$internalSynchronizedKey = $this->createInternalKey($synchronizedKeyFromUser); |
| 238 |
|
| 239 |
$currentLockHolder = $this->ownerStore->readOwner($internalSynchronizedKey); |
| 240 |
|
| 241 |
// Whatever the outcome below, this request is done with the lock, so |
| 242 |
// the shutdown release must no longer consider it outstanding. |
| 243 |
$this->forgetHeldLock($internalSynchronizedKey, $uniqueID); |
| 244 |
|
| 245 |
if ($uniqueID == $currentLockHolder) { |
| 246 |
$this->ownerStore->deleteOwner(array( |
| 247 |
'key' => $internalSynchronizedKey, |
| 248 |
'owner' => $uniqueID, |
| 249 |
)); |
| 250 |
|
| 251 |
} else { |
| 252 |
// Fail silently instead of throwing fatal exception. |
| 253 |
$logger = abj_service('logging'); |
| 254 |
$logger->debugMessage("Synchronization lock release mismatch. " . |
| 255 |
"Synchronized key: $synchronizedKeyFromUser, current holder: $currentLockHolder, " . |
| 256 |
"attempted release by: $uniqueID"); |
| 257 |
} |
| 258 |
} |
| 259 |
|
| 260 |
/** How long, in seconds, an owner record may sit before a later acquirer |
| 261 |
* treats it as leaked and breaks it. |
| 262 |
* |
| 263 |
* Derived from max_execution_time because a live request cannot outlive it, |
| 264 |
* but capped at LOCK_STALE_CEILING_SECONDS because that ini value is |
| 265 |
* host-controlled and unbounded. See the constant for the incident this |
| 266 |
* ceiling exists to prevent. |
| 267 |
* |
| 268 |
* @return int |
| 269 |
*/ |
| 270 |
private function staleLockThresholdSeconds() { |
| 271 |
$maxExecutionTime = ini_get('max_execution_time'); |
| 272 |
|
| 273 |
if (empty($maxExecutionTime) || !is_numeric($maxExecutionTime) || (int)$maxExecutionTime < 1) { |
| 274 |
return self::LOCK_STALE_FALLBACK_SECONDS; |
| 275 |
} |
| 276 |
|
| 277 |
return (int) min((int)$maxExecutionTime * 2, self::LOCK_STALE_CEILING_SECONDS); |
| 278 |
} |
| 279 |
|
| 280 |
/** Record that this request now owns $internalSynchronizedKey, and make |
| 281 |
* sure the shutdown release hook is wired. |
| 282 |
* |
| 283 |
* @param string $internalSynchronizedKey |
| 284 |
* @param string $uniqueID |
| 285 |
* @return void |
| 286 |
*/ |
| 287 |
private function rememberHeldLock($internalSynchronizedKey, $uniqueID) { |
| 288 |
$this->locksHeldThisRequest[$internalSynchronizedKey] = $uniqueID; |
| 289 |
|
| 290 |
if ($this->shutdownReleaseRegistered) { |
| 291 |
return; |
| 292 |
} |
| 293 |
$this->shutdownReleaseRegistered = true; |
| 294 |
|
| 295 |
// Two hooks, same idempotent handler, because they run at different |
| 296 |
// points and only one of them is always available. |
| 297 |
// |
| 298 |
// WordPress registers shutdown_action_hook() (which fires the |
| 299 |
// 'shutdown' action and THEN calls wp_cache_close()) from |
| 300 |
// wp-settings.php, long before plugins load -- so it always runs |
| 301 |
// before anything this plugin can register. Releasing from the |
| 302 |
// 'shutdown' action therefore happens while the object cache is still |
| 303 |
// open, which matters in options mode: a delete_option() whose cache |
| 304 |
// invalidation silently failed would leave other requests reading the |
| 305 |
// released owner record straight out of a persistent object cache, |
| 306 |
// recreating the very wedge this release exists to prevent. |
| 307 |
// |
| 308 |
// The raw shutdown function is the backstop for the cases the action |
| 309 |
// cannot cover: a fatal before WordPress's action system is usable, or |
| 310 |
// a site where something unhooked shutdown_action_hook(). |
| 311 |
if (function_exists('add_action')) { |
| 312 |
add_action('shutdown', array($this, 'releaseLocksLeakedByThisRequest')); |
| 313 |
} |
| 314 |
register_shutdown_function(array($this, 'releaseLocksLeakedByThisRequest')); |
| 315 |
} |
| 316 |
|
| 317 |
/** Drop $internalSynchronizedKey from the outstanding set, but only when |
| 318 |
* the caller is releasing the same acquisition we recorded. A double |
| 319 |
* release of an old unique ID must not cancel the crash-safe release of a |
| 320 |
* newer acquisition of the same key in the same request. |
| 321 |
* |
| 322 |
* @param string $internalSynchronizedKey |
| 323 |
* @param string $uniqueID |
| 324 |
* @return void |
| 325 |
*/ |
| 326 |
private function forgetHeldLock($internalSynchronizedKey, $uniqueID) { |
| 327 |
if (array_key_exists($internalSynchronizedKey, $this->locksHeldThisRequest) |
| 328 |
&& $this->locksHeldThisRequest[$internalSynchronizedKey] === $uniqueID) { |
| 329 |
unset($this->locksHeldThisRequest[$internalSynchronizedKey]); |
| 330 |
} |
| 331 |
} |
| 332 |
|
| 333 |
/** Shutdown hook: release any lock this request acquired but never released. |
| 334 |
* |
| 335 |
* Callers all release in a finally block, which covers thrown exceptions. |
| 336 |
* It does NOT cover the failure modes that actually leak a lock: a fatal |
| 337 |
* error, memory exhaustion, or a request timeout terminates the request |
| 338 |
* without unwinding, so `finally` never runs. PHP does still run shutdown |
| 339 |
* functions in those cases, which makes this the only place a leaked lock |
| 340 |
* can be reclaimed by the process that leaked it. |
| 341 |
* |
| 342 |
* Public because register_shutdown_function() has to be able to call it; |
| 343 |
* it is idempotent and only ever deletes an owner record whose value still |
| 344 |
* matches a unique ID this request minted, so a lock that has since been |
| 345 |
* broken or taken over by another request is left alone. |
| 346 |
* |
| 347 |
* It is also RESUMABLE, which is a stronger property than idempotent and |
| 348 |
* the reason each key is dropped from the outstanding map individually, |
| 349 |
* after its own owner record is gone, rather than clearing the map up |
| 350 |
* front. This method can be re-entered from the top while a pass is still |
| 351 |
* suspended mid-loop: PHP's LiteSpeed SAPI handles SIGTERM by calling |
| 352 |
* php_request_shutdown() from inside the signal handler |
| 353 |
* (lsapi_main.c:714-728), which fires the 'shutdown' action again, and |
| 354 |
* this method is deliberately hooked both there and on |
| 355 |
* register_shutdown_function(). Under LSAPI the handler then calls |
| 356 |
* exit(1), so the suspended pass never resumes and the re-entrant pass is |
| 357 |
* the last one that runs. Emptying the map before the deletes would leave |
| 358 |
* that final pass with nothing to do and leak every lock the interrupted |
| 359 |
* pass had not reached yet, deferring the next database or version upgrade |
| 360 |
* until the stale-lock breaker fires (up to LOCK_STALE_CEILING_SECONDS). |
| 361 |
* |
| 362 |
* @return void |
| 363 |
*/ |
| 364 |
function releaseLocksLeakedByThisRequest() { |
| 365 |
if (empty($this->locksHeldThisRequest)) { |
| 366 |
return; |
| 367 |
} |
| 368 |
|
| 369 |
// Snapshot the keys only, so a re-entrant pass that releases and forgets |
| 370 |
// some of them cannot make this foreach skip a key or trip over a |
| 371 |
// mutation mid-iteration. The map itself stays authoritative: each key |
| 372 |
// is re-read from it below and left in place until its record is gone. |
| 373 |
foreach (array_keys($this->locksHeldThisRequest) as $internalSynchronizedKey) { |
| 374 |
if (!array_key_exists($internalSynchronizedKey, $this->locksHeldThisRequest)) { |
| 375 |
// A re-entrant pass already released this one. |
| 376 |
continue; |
| 377 |
} |
| 378 |
$uniqueID = $this->locksHeldThisRequest[$internalSynchronizedKey]; |
| 379 |
|
| 380 |
try { |
| 381 |
if ($this->ownerStore->readOwner($internalSynchronizedKey) !== $uniqueID) { |
| 382 |
// Already broken by the stale-lock heuristic, taken over by |
| 383 |
// another request, or released by a re-entrant pass. Not |
| 384 |
// ours to delete, and nothing left to retry. |
| 385 |
$this->forgetHeldLock($internalSynchronizedKey, $uniqueID); |
| 386 |
continue; |
| 387 |
} |
| 388 |
|
| 389 |
$this->ownerStore->deleteOwner(array( |
| 390 |
'key' => $internalSynchronizedKey, |
| 391 |
'owner' => $uniqueID, |
| 392 |
)); |
| 393 |
|
| 394 |
// The record is gone, so this key's work is durably done. Drop |
| 395 |
// it before anything else can throw: a key still in the map is |
| 396 |
// a key a later pass will retry, and retrying a delete could |
| 397 |
// remove a record another request has since acquired. |
| 398 |
$this->forgetHeldLock($internalSynchronizedKey, $uniqueID); |
| 399 |
|
| 400 |
$logger = abj_service('logging'); |
| 401 |
$logger->warn("Released a synchronization lock that this request " . |
| 402 |
"acquired but never released (the request ended without reaching the " . |
| 403 |
"release call, e.g. a fatal error, memory exhaustion, or a timeout " . |
| 404 |
"inside the critical section). Key: " . $internalSynchronizedKey . |
| 405 |
", value: " . $uniqueID); |
| 406 |
|
| 407 |
} catch (Throwable $e) { |
| 408 |
// Shutdown context: the logging service (or whatever fataled) |
| 409 |
// may no longer be usable, so fall back to the centralized raw |
| 410 |
// PHP error-log sink rather than losing the failure. The key |
| 411 |
// stays in the outstanding map on this path on purpose -- a |
| 412 |
// delete that threw is unfinished work, and the second shutdown |
| 413 |
// hook (or a re-entrant pass) has to be able to retry it. |
| 414 |
if (function_exists('abj404_logPhpFallback')) { |
| 415 |
abj404_logPhpFallback('fatal-handler-fallback', |
| 416 |
'Failed to release leaked synchronization lock ' . |
| 417 |
$internalSynchronizedKey . ': ' . $e->getMessage()); |
| 418 |
} |
| 419 |
} |
| 420 |
} |
| 421 |
} |
| 422 |
|
| 423 |
/** |
| 424 |
* @return string a random string of characters. |
| 425 |
* @throws Exception |
| 426 |
*/ |
| 427 |
function uniqidReal() { |
| 428 |
$bytes = null; |
| 429 |
if (function_exists("random_bytes")) { |
| 430 |
try { |
| 431 |
$bytes = random_bytes(max(1, (int)ceil(13 / 2))); |
| 432 |
} catch (Exception $e) { // allow-silent-catch: random_bytes unavailable; fall through to openssl then uniqid |
| 433 |
$bytes = null; |
| 434 |
} |
| 435 |
} |
| 436 |
|
| 437 |
if ($bytes == null && function_exists("openssl_random_pseudo_bytes")) { |
| 438 |
try { |
| 439 |
$bytes = openssl_random_pseudo_bytes((int)ceil(13 / 2)); |
| 440 |
} catch (Exception $e) { // allow-silent-catch: openssl fallback unavailable; fall through to uniqid |
| 441 |
$bytes = null; |
| 442 |
} |
| 443 |
} |
| 444 |
|
| 445 |
if ($bytes != null) { |
| 446 |
return bin2hex($bytes); |
| 447 |
} |
| 448 |
return uniqid("", true); |
| 449 |
} |
| 450 |
|
| 451 |
} |
| 452 |
|