Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StagingCommandLock
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 11
2550
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 read
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 parse
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 isValidToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 holds
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 heldByOther
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 refresh
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 heldByOwnLiveProcess
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 pidIsRunning
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 releaseIfOurs
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
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
8namespace 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 */
51class 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}