PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.10.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.10.0
4.10.0 4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Framework / Job / ProcessLock.php
wp-staging / Framework / Job Last commit date
Ajax 5 days ago BackgroundProcessing 1 year ago Dto 2 weeks ago Exception 5 days ago Interfaces 1 month ago Jobs 7 months ago Task 1 month ago Traits 10 months ago AbstractJob.php 5 days ago JobProvider.php 1 year ago JobServiceProvider.php 6 months ago JobTransientCache.php 3 months ago ProcessLock.php 5 days ago
ProcessLock.php
224 lines
1 <?php
2
3 namespace WPStaging\Framework\Job;
4
5 use RuntimeException;
6 use WPStaging\Core\WPStaging;
7 use WPStaging\Framework\Job\Exception\ProcessLockedException;
8 use WPStaging\Framework\Traits\ResourceTrait;
9
10 class ProcessLock
11 {
12 use ResourceTrait;
13
14 const LOCK_FILE_NAME = '.wpstg_process_locked';
15
16 /** @var string */
17 private $lockFile;
18
19 /**
20 * The open, flock()ed handle of the lock this request holds, or null when it holds none.
21 *
22 * It is static so that every ProcessLock instance of a request shares one lock identity, and it
23 * stays open for as long as the lock is held: the kernel releases an flock() when the last
24 * descriptor referring to it is closed, which PHP does at the end of the request - including
25 * when the request dies from a fatal error, a timeout or a killed worker. The lock therefore
26 * lives exactly as long as the process that owns it, so a crashed owner never blocks anyone.
27 *
28 * @var resource|null
29 */
30 private static $handle = null;
31
32 public function __construct()
33 {
34 $this->lockFile = trailingslashit(WPStaging::getContentDir()) . self::LOCK_FILE_NAME;
35 }
36
37 /**
38 * Acquire the global process lock for the current request.
39 *
40 * The lock is an exclusive, non-blocking flock() on an open descriptor. When two workers race -
41 * e.g. the background processing loopback and a wp-cron request spawned milliseconds apart - the
42 * kernel hands the lock to exactly one of them, so they can never both run the same job. The
43 * previous check-then-file_put_contents() implementation let both "acquire" and corrupt it.
44 *
45 * @throws ProcessLockedException When another live request already holds the lock.
46 * @throws RuntimeException When the lock file cannot be created at all.
47 * @return void
48 */
49 public function lockProcess()
50 {
51 // Re-entrant: this request already holds the lock, e.g. a background worker running several
52 // job steps within one request.
53 if (self::$handle !== null) {
54 return;
55 }
56
57 $handle = $this->openLockFile();
58
59 if (!$this->acquireHandle($handle)) {
60 fclose($handle);
61
62 throw ProcessLockedException::processAlreadyLocked();
63 }
64
65 $this->writeLockedAt($handle);
66
67 self::$handle = $handle;
68 }
69
70 /**
71 * Release the lock held by this request. Never touches a lock held by another request.
72 *
73 * @return void
74 */
75 public function unlockProcess()
76 {
77 $handle = self::$handle;
78
79 if ($handle === null) {
80 return;
81 }
82
83 self::$handle = null;
84
85 // The file is emptied, never unlinked. Deleting it would let a contender that already
86 // decided the old lock was stale unlink the file out from under the next owner's lock,
87 // leaving two winners on two different inodes.
88 ftruncate($handle, 0);
89 flock($handle, LOCK_UN);
90 fclose($handle);
91 }
92
93 /**
94 * Tell whether another live request holds the lock, without keeping it.
95 *
96 * @param int|null $timeout Staleness timeout in seconds, used only as a fallback on filesystems
97 * that cannot lock. Null derives it from the request time limit.
98 * @throws ProcessLockedException When another live request holds the lock.
99 * @return void
100 */
101 public function checkProcessLocked($timeout = null)
102 {
103 if (self::$handle !== null) {
104 return;
105 }
106
107 $handle = $this->openLockFile();
108 $wouldBlock = 0;
109
110 // A shared lock answers "is somebody holding this exclusively?" without making concurrent
111 // readers wait for each other. It is dropped again immediately.
112 if (flock($handle, LOCK_SH | LOCK_NB, $wouldBlock)) {
113 flock($handle, LOCK_UN);
114 fclose($handle);
115
116 return;
117 }
118
119 fclose($handle);
120
121 if ($wouldBlock || !$this->isLockRecordStale($timeout)) {
122 throw ProcessLockedException::processAlreadyLocked();
123 }
124 }
125
126 /**
127 * @param resource $handle
128 * @return bool True when this request now owns the lock.
129 */
130 private function acquireHandle($handle): bool
131 {
132 $wouldBlock = 0;
133
134 if (flock($handle, LOCK_EX | LOCK_NB, $wouldBlock)) {
135 return true;
136 }
137
138 // $wouldBlock is set only when the lock was refused because somebody else holds it. Any
139 // other failure means the filesystem cannot lock at all (an NFS mount without a lock
140 // daemon, an exotic stream); treating that as contention would wedge the plugin forever, so
141 // fall back to the timestamp record - best effort, but never worse than no lock at all.
142 if ($wouldBlock) {
143 return false;
144 }
145
146 return $this->isLockRecordStale();
147 }
148
149 /**
150 * Record when the lock was taken. Only the fallback path above reads it back, so a record that
151 * cannot be written fully is cleared rather than left half-written: the flock() is what actually
152 * guarantees exclusion.
153 *
154 * @param resource $handle
155 * @return void
156 */
157 private function writeLockedAt($handle)
158 {
159 $lockedAt = (string)time();
160
161 ftruncate($handle, 0);
162 rewind($handle);
163
164 if (fwrite($handle, $lockedAt) !== strlen($lockedAt)) {
165 ftruncate($handle, 0);
166 }
167
168 fflush($handle);
169 }
170
171 /**
172 * @param int|null $timeout
173 * @return bool True when no owner recorded itself recently, so the lock can be taken over.
174 */
175 private function isLockRecordStale($timeout = null): bool
176 {
177 if (is_null($timeout)) {
178 $timeout = min(120, $this->getTimeLimit());
179 }
180
181 if (!file_exists($this->lockFile)) {
182 return true;
183 }
184
185 $lockedAt = file_get_contents($this->lockFile);
186
187 // An empty record is a released lock, a non-numeric one is a corrupt lock. Neither may wedge
188 // the queue.
189 if (!is_numeric($lockedAt)) {
190 return true;
191 }
192
193 return (int)$lockedAt < time() - $timeout;
194 }
195
196 /**
197 * @throws RuntimeException When the lock file cannot be opened or created.
198 * @return resource
199 */
200 private function openLockFile()
201 {
202 // fopen() warns when the path is not writable; capture that with a scoped error handler
203 // instead of the @ operator (project code style rule).
204 $error = '';
205 set_error_handler(function ($errno, $errstr) use (&$error) {
206 $error = $errstr;
207
208 return true;
209 });
210
211 // 'c+' creates the file when it is missing and never truncates it, so opening the file can
212 // neither disturb a lock another request holds on it nor publish a half-initialized record.
213 $handle = fopen($this->lockFile, 'c+');
214
215 restore_error_handler();
216
217 if ($handle === false) {
218 throw new RuntimeException(sprintf('Could not open the process lock file %s. %s', $this->lockFile, $error));
219 }
220
221 return $handle;
222 }
223 }
224