Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 73 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
| StagingCommandLock | |
0.00% |
0 / 73 |
|
0.00% |
0 / 11 |
2550 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| path | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| read | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| parse | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
90 | |||
| isValidToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
| holds | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| heldByOther | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| refresh | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
| heldByOwnLiveProcess | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| pidIsRunning | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
| releaseIfOurs | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
42 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Reads and refreshes the staging command mutex that lib/.staging owns. |
| 4 | * |
| 5 | * @package NewfoldLabs\WP\Module\Staging |
| 6 | */ |
| 7 | |
| 8 | namespace NewfoldLabs\WP\Module\Staging; |
| 9 | |
| 10 | /** |
| 11 | * The PHP half of the staging command mutex. |
| 12 | * |
| 13 | * Only ONE staging operation (create, clone, destroy, a legacy deploy, or a stepped content deploy) |
| 14 | * may run against a site at a time: they all move, rsync, `rm -rf` and rename the same production and |
| 15 | * staging trees, so two at once can delete files out from under each other and promote a half-built |
| 16 | * tree over live production. lib/.staging's `lock_check` is what enforces that, and this class is how |
| 17 | * PHP takes part in the same lock. |
| 18 | * |
| 19 | * ## Why a file, and not the WordPress transient this used to be |
| 20 | * |
| 21 | * lib/.staging runs WP-CLI with the object-cache drop-in skipped (see the NFD_WP_EXEC comment there), |
| 22 | * so `wp_using_ext_object_cache()` is false inside the script while it is true for this request. |
| 23 | * Anything the two sides share therefore cannot live anywhere that moves with the object cache. A |
| 24 | * transient does: bash wrote it to the database as an option while a persistent cache in front of |
| 25 | * `get_transient()` here would answer from a stale (or absent) cached copy. Worse, the lock belongs to |
| 26 | * PRODUCTION's install, and a deploy is driven from the STAGING install, whose `$wpdb` points at a |
| 27 | * different table prefix -- so a naive `get_transient()` here would read the wrong table entirely and |
| 28 | * see no lock at all. A plain file under production's `nfd-private` has neither problem: both sides |
| 29 | * address it by absolute path, there is no cache and no prefix, and `nfd-private/.htaccess` (written |
| 30 | * by lib/.staging's `log`) already denies it to the web. |
| 31 | * |
| 32 | * ## Format |
| 33 | * |
| 34 | * One line: `active:<REFRESHED_AT>:<TOKEN>:<PID>` |
| 35 | * |
| 36 | * - REFRESHED_AT -- unix seconds, rewritten on every refresh (NOT the original acquire time). |
| 37 | * - TOKEN -- opaque `[A-Za-z0-9]{1,64}` identifying the operation holding the lock. A stepped |
| 38 | * content deploy uses its per-job token (ContentCopyController), so each of its |
| 39 | * bash bookends re-presents the same token and refreshes the lock it already owns |
| 40 | * instead of blocking on it. Other commands get a random one from bash. |
| 41 | * - PID -- the bash process holding the lock, or 0 when no single process does (a stepped |
| 42 | * job between bursts, which this class refreshes). |
| 43 | * |
| 44 | * ## Division of labour |
| 45 | * |
| 46 | * bash is the authority: `lock_check` is the only thing that ACQUIRES or STEALS the lock, and it alone |
| 47 | * decides a holder is dead (TTL plus a `kill -0` liveness test on PID). This class only ever READS the |
| 48 | * lock, or REFRESHES one that already carries our own token. It never creates, steals or releases one, |
| 49 | * so it cannot hand two operations the same mutex. |
| 50 | */ |
| 51 | class StagingCommandLock { |
| 52 | |
| 53 | /** |
| 54 | * Staging content-lock protocol version (file mutex, stepped deploy, compat channel). |
| 55 | * |
| 56 | * Bump when lib/.staging and PHP disagree on lock semantics; version_handshake compares this |
| 57 | * across installs, not the brand plugin release number. Keep in sync with |
| 58 | * STAGING_CONTENT_LOCK_PROTOCOL in lib/.staging. |
| 59 | */ |
| 60 | const CONTENT_LOCK_PROTOCOL = 1; |
| 61 | |
| 62 | /** |
| 63 | * Seconds after the last refresh that a lock is treated as possibly abandoned. Must stay in step |
| 64 | * with LOCK_TTL in lib/.staging. |
| 65 | */ |
| 66 | const TTL = 1800; |
| 67 | |
| 68 | /** |
| 69 | * How far inside the TTL a lock must be before releaseIfOurs() will remove it by path. |
| 70 | * |
| 71 | * Deleting by path cannot be made atomic against lib/.staging deciding the same lock is abandoned |
| 72 | * and replacing it with its own, so the release only runs on a lock far enough from its TTL that |
| 73 | * no steal is permitted. Without the margin the two could in principle cross at the exact instant |
| 74 | * of the TTL boundary; with it, that would need the method to stall for a minute between reading |
| 75 | * the lock and unlinking it. |
| 76 | */ |
| 77 | const RELEASE_MARGIN = 60; |
| 78 | |
| 79 | /** |
| 80 | * Lock path relative to the production directory. Must stay in step with LOCK_FILE in lib/.staging. |
| 81 | */ |
| 82 | const RELATIVE_PATH = 'nfd-private/nfd-staging-command.lock'; |
| 83 | |
| 84 | /** |
| 85 | * Absolute path to the lock file. |
| 86 | * |
| 87 | * @var string |
| 88 | */ |
| 89 | private $path; |
| 90 | |
| 91 | /** |
| 92 | * Build a lock handle for a site. |
| 93 | * |
| 94 | * @param string $production_dir Absolute production install root (the lock always lives with |
| 95 | * production, even when the request runs in the staging install). |
| 96 | */ |
| 97 | public function __construct( $production_dir ) { |
| 98 | $this->path = rtrim( (string) $production_dir, '/' ) . '/' . self::RELATIVE_PATH; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * Absolute path to the lock file. |
| 103 | * |
| 104 | * @return string |
| 105 | */ |
| 106 | public function path() { |
| 107 | return $this->path; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Read and parse the current lock. |
| 112 | * |
| 113 | * An unreadable or unparseable lock is reported as a lock with no token and no pid, timestamped |
| 114 | * from the file's mtime, rather than as no lock at all: a truncated file (a crash between the |
| 115 | * exclusive create and the write) still means somebody claimed the mutex. |
| 116 | * |
| 117 | * @return array|null { timestamp: int, token: string, pid: int } or null when no lock exists. |
| 118 | */ |
| 119 | public function read() { |
| 120 | clearstatcache( true, $this->path ); |
| 121 | if ( ! is_file( $this->path ) ) { |
| 122 | return null; |
| 123 | } |
| 124 | |
| 125 | $raw = @file_get_contents( $this->path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.PHP.NoSilencedErrors.Discouraged |
| 126 | |
| 127 | return $this->parse( false === $raw ? '' : $raw ); |
| 128 | } |
| 129 | |
| 130 | /** |
| 131 | * Parse a lock value, falling back to the file's mtime when it carries no usable timestamp. |
| 132 | * |
| 133 | * @param string $raw Raw file contents. |
| 134 | * |
| 135 | * @return array { timestamp: int, token: string, pid: int } |
| 136 | */ |
| 137 | private function parse( $raw ) { |
| 138 | $fields = explode( ':', trim( (string) $raw ) ); |
| 139 | |
| 140 | $timestamp = isset( $fields[1] ) && ctype_digit( $fields[1] ) ? (int) $fields[1] : 0; |
| 141 | if ( 0 === $timestamp ) { |
| 142 | $mtime = @filemtime( $this->path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 143 | $timestamp = false === $mtime ? 0 : (int) $mtime; |
| 144 | } |
| 145 | |
| 146 | $token = isset( $fields[2] ) && $this->isValidToken( $fields[2] ) ? $fields[2] : ''; |
| 147 | $pid = isset( $fields[3] ) && ctype_digit( $fields[3] ) ? (int) $fields[3] : 0; |
| 148 | |
| 149 | return array( |
| 150 | 'timestamp' => $timestamp, |
| 151 | 'token' => $token, |
| 152 | 'pid' => $pid, |
| 153 | ); |
| 154 | } |
| 155 | |
| 156 | /** |
| 157 | * Whether a token is well formed. Keeps a token out of the colon-delimited lock format and out of |
| 158 | * a shell argument. |
| 159 | * |
| 160 | * @param string $token Token to check. |
| 161 | * |
| 162 | * @return bool |
| 163 | */ |
| 164 | public function isValidToken( $token ) { |
| 165 | return is_string( $token ) && (bool) preg_match( '/^[A-Za-z0-9]{1,64}$/', $token ); |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Whether the lock currently carries this token (regardless of age). |
| 170 | * |
| 171 | * @param string $token Our token. |
| 172 | * |
| 173 | * @return bool |
| 174 | */ |
| 175 | public function holds( $token ) { |
| 176 | if ( ! $this->isValidToken( $token ) ) { |
| 177 | return false; |
| 178 | } |
| 179 | |
| 180 | $lock = $this->read(); |
| 181 | |
| 182 | return null !== $lock && $lock['token'] === $token; |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * Whether a live lock belonging to SOMEONE ELSE is in place, i.e. starting a new operation now |
| 187 | * would collide with one already running. |
| 188 | * |
| 189 | * Deliberately TTL-only, with no process-liveness test. bash's `lock_check` applies the stronger |
| 190 | * test (a lock whose recorded pid is still alive is held even past the TTL), and it runs |
| 191 | * immediately after this on the same operation. Erring in that direction keeps this fast path from |
| 192 | * refusing an operation bash would have allowed; the only cost of being wrong is that bash reports |
| 193 | * the collision instead of this method. |
| 194 | * |
| 195 | * @param string $token Our token ('' when we hold no lock of our own). |
| 196 | * |
| 197 | * @return bool |
| 198 | */ |
| 199 | public function heldByOther( $token ) { |
| 200 | $lock = $this->read(); |
| 201 | if ( null === $lock ) { |
| 202 | return false; |
| 203 | } |
| 204 | |
| 205 | if ( '' !== (string) $token && $lock['token'] === $token ) { |
| 206 | return false; |
| 207 | } |
| 208 | |
| 209 | return ( time() - $lock['timestamp'] ) <= self::TTL; |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Push the lock's timestamp forward, but only while it still carries our own token. |
| 214 | * |
| 215 | * This is what keeps a stepped content deploy holding the mutex for its whole run: the copy phase |
| 216 | * is PHP, with no bash process alive between bursts, so without a refresh here the lock bash took |
| 217 | * at setup would age out mid-copy and another operation could take it. |
| 218 | * |
| 219 | * The rewrite is done IN PLACE on a file handle opened before the token was checked, which is the |
| 220 | * point: if bash concluded the lock was abandoned and took it, it did so by removing the file and |
| 221 | * exclusively creating a new one, so this write lands on the inode we verified -- now unlinked -- |
| 222 | * and cannot clobber the new holder's lock. The re-read afterwards is by path, so it sees the |
| 223 | * theft and reports failure. A caller that cannot refresh must treat its job as no longer holding |
| 224 | * the mutex and stop. |
| 225 | * |
| 226 | * @param string $token Our token. |
| 227 | * |
| 228 | * @return bool True when the lock is ours and now carries a fresh timestamp. |
| 229 | */ |
| 230 | public function refresh( $token ) { |
| 231 | if ( ! $this->isValidToken( $token ) ) { |
| 232 | return false; |
| 233 | } |
| 234 | |
| 235 | $handle = @fopen( $this->path, 'r+' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged |
| 236 | if ( false === $handle ) { |
| 237 | return false; |
| 238 | } |
| 239 | |
| 240 | $written = false; |
| 241 | if ( flock( $handle, LOCK_EX ) ) { |
| 242 | $raw = stream_get_contents( $handle ); |
| 243 | if ( $this->parse( false === $raw ? '' : $raw )['token'] === $token ) { |
| 244 | $value = 'active:' . time() . ':' . $token . ':0'; |
| 245 | if ( 0 === fseek( $handle, 0 ) && false !== fwrite( $handle, $value ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite |
| 246 | ftruncate( $handle, strlen( $value ) ); |
| 247 | fflush( $handle ); |
| 248 | $written = true; |
| 249 | } |
| 250 | } |
| 251 | flock( $handle, LOCK_UN ); |
| 252 | } |
| 253 | fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose |
| 254 | |
| 255 | return $written && $this->holds( $token ); |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * Whether the lock is ours AND records a bash process that is still running. |
| 260 | * |
| 261 | * This is the difference between "an earlier verb of this job is still working" and "an earlier |
| 262 | * verb died". Both leave a lock carrying our own token, because a SIGKILLed verb runs no EXIT trap |
| 263 | * and so never releases, and the two need opposite answers: the first must be waited for, the |
| 264 | * second is a failure. Without this a request killed on the FPM timeout mid-finalize was reported |
| 265 | * to the customer as a failed deploy while its orphan completed the swap successfully. |
| 266 | * |
| 267 | * Only a bash verb writes a pid; refresh() above writes 0, so pid 0 means nothing of ours is in |
| 268 | * flight. Biased towards "not running": being wrong that way costs a retry prompt on a deploy that |
| 269 | * is already over, while being wrong the other way would poll until the job goes stale. |
| 270 | * |
| 271 | * @param string $token Our token. |
| 272 | * |
| 273 | * @return bool |
| 274 | */ |
| 275 | public function heldByOwnLiveProcess( $token ) { |
| 276 | if ( ! $this->isValidToken( $token ) ) { |
| 277 | return false; |
| 278 | } |
| 279 | |
| 280 | $lock = $this->read(); |
| 281 | if ( null === $lock || $lock['token'] !== $token ) { |
| 282 | return false; |
| 283 | } |
| 284 | |
| 285 | // An unknown answer counts as NOT running. The consequence of being wrong here is that a job |
| 286 | // is failed and the customer retries a deploy that was about to finish; the consequence of the |
| 287 | // opposite default is polling until the stale window expires, an hour later. releaseIfOurs |
| 288 | // takes the opposite default for the same reason: see the note there. |
| 289 | return true === $this->pidIsRunning( $lock['pid'] ); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Whether a recorded lock holder is still running: true, false, or null when it cannot be told. |
| 294 | * |
| 295 | * Pid 0 is not a process. It is what refresh() writes, and only refresh() writes it, so it means |
| 296 | * no bash verb has run under this lock since the last PHP copy burst -- definitively nothing of |
| 297 | * ours in flight. |
| 298 | * |
| 299 | * Protected rather than private so a test can substitute the answer. The "cannot tell" branch is |
| 300 | * otherwise unreachable in a test: it needs a host with neither the POSIX extension nor /proc, and |
| 301 | * both of the callers' defaults hang off exactly that answer. |
| 302 | * |
| 303 | * @param int $pid Recorded pid. |
| 304 | * |
| 305 | * @return bool|null |
| 306 | */ |
| 307 | protected function pidIsRunning( $pid ) { |
| 308 | if ( $pid < 2 ) { |
| 309 | return false; |
| 310 | } |
| 311 | |
| 312 | // `posix_kill($pid, 0)` is the direct test, and the only one that can distinguish the two ways |
| 313 | // it fails. The POSIX extension is not always enabled on shared hosting, hence the fallback. |
| 314 | if ( function_exists( 'posix_kill' ) && function_exists( 'posix_get_last_error' ) ) { |
| 315 | if ( @posix_kill( $pid, 0 ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 316 | return true; |
| 317 | } |
| 318 | |
| 319 | // ESRCH, which is 3 on Linux and on macOS, is the only definite "there is no such |
| 320 | // process". EPERM means a process IS there and belongs to another user, which cannot be |
| 321 | // one of our own verbs but is not something to act on either -- so it falls through to the |
| 322 | // unknown answer below rather than being read as gone. |
| 323 | if ( 3 === posix_get_last_error() ) { |
| 324 | return false; |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // /proc as the fallback. Silenced because under open_basedir these emit a warning, and a |
| 329 | // warning here reaches the REST response body and breaks the JSON the poll loop parses. |
| 330 | if ( @is_dir( '/proc/' . $pid ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 331 | return true; |
| 332 | } |
| 333 | |
| 334 | // "Not in /proc" is only an answer on a host where /proc can be read at all; macOS has none. |
| 335 | return @is_dir( '/proc/self' ) ? false : null; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged |
| 336 | } |
| 337 | |
| 338 | /** |
| 339 | * Free the lock when the caller's PHP-side job has failed and is certainly the only thing holding |
| 340 | * it. |
| 341 | * |
| 342 | * Without this, a job that fails during the copy phase -- a full disk, an unreadable manifest -- |
| 343 | * leaves the lock it took at setup in place until the TTL runs out, blocking every other staging |
| 344 | * operation for up to half an hour over a deploy that is already over. The old transient lock did |
| 345 | * not have that problem, because every failure path went through bash's error(), which released. |
| 346 | * |
| 347 | * Three conditions, all required, and all of them about being sure nothing else is involved: |
| 348 | * |
| 349 | * - the lock carries this job's token, so it is ours to free; |
| 350 | * - nothing is running under it. Pid 0, which only refresh() writes, means no bash verb has run |
| 351 | * since the last copy burst. A recorded pid means a verb DID run, and it may be an orphan |
| 352 | * still working -- a killed request leaves setup's `rm -rf` running under this same token -- |
| 353 | * so that lock may only be freed once its holder is positively gone. An answer of "cannot |
| 354 | * tell" counts as still running: the cost of being wrong is waiting out the TTL, where the |
| 355 | * cost of the opposite default is freeing the mutex under a live `rm -rf`; |
| 356 | * - it is comfortably within the TTL (by RELEASE_MARGIN), so lib/.staging may not concurrently |
| 357 | * decide it is abandoned and claim it. That is what makes deleting by path safe: a lock |
| 358 | * nobody may steal right now cannot turn into somebody else's between the check and the |
| 359 | * unlink. For a lock already at or past the TTL there is nothing to gain anyway -- it no |
| 360 | * longer blocks anyone. |
| 361 | * |
| 362 | * Testing liveness rather than insisting on pid 0 is what makes this useful on the path that |
| 363 | * actually matters. Restarting after a setup verb outlived its request -- which the UI does by |
| 364 | * itself when the first poll times out -- leaves a lock carrying that verb's pid, and refusing it |
| 365 | * on the pid alone meant the retry could not free it, could no longer even name its token once |
| 366 | * the state file went, and the site refused every staging operation for the full half hour. |
| 367 | * |
| 368 | * @param string $token Our token. |
| 369 | * |
| 370 | * @return bool True when a lock was removed. |
| 371 | */ |
| 372 | public function releaseIfOurs( $token ) { |
| 373 | if ( ! $this->isValidToken( $token ) ) { |
| 374 | return false; |
| 375 | } |
| 376 | |
| 377 | $lock = $this->read(); |
| 378 | if ( null === $lock || $lock['token'] !== $token ) { |
| 379 | return false; |
| 380 | } |
| 381 | |
| 382 | if ( false !== $this->pidIsRunning( $lock['pid'] ) ) { |
| 383 | return false; |
| 384 | } |
| 385 | |
| 386 | if ( ( time() - $lock['timestamp'] ) > ( self::TTL - self::RELEASE_MARGIN ) ) { |
| 387 | return false; |
| 388 | } |
| 389 | |
| 390 | return @unlink( $this->path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink, WordPress.PHP.NoSilencedErrors.Discouraged |
| 391 | } |
| 392 | } |