PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / SynchronizationUtils.php

SynchronizationUtils.php in 404 Solution trunk, at includes/core/SynchronizationUtils.php

452 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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