Completed
Push — master ( 3fd10f...dc1dc7 )
by Anatoliy
11:29 queued 03:28
created

DenyMultiplyRun::validatePid()   B

Complexity

Conditions 5
Paths 2

Size

Total Lines 30
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 30
ccs 9
cts 9
cp 1
rs 8.439
c 0
b 0
f 0
cc 5
eloc 14
nc 2
nop 1
crap 5
1
<?php
2
declare(strict_types = 1);
3
4
namespace DanchukAS\DenyMultiplyRun;
5
6
use DanchukAS\DenyMultiplyRun\Exception\CloseFileFail;
7
use DanchukAS\DenyMultiplyRun\Exception\ConvertPidFail;
8
use DanchukAS\DenyMultiplyRun\Exception\DeleteFileFail;
9
use DanchukAS\DenyMultiplyRun\Exception\FileExisted;
10
use DanchukAS\DenyMultiplyRun\Exception\LockFileFail;
11
use DanchukAS\DenyMultiplyRun\Exception\OpenFileFail;
12
use DanchukAS\DenyMultiplyRun\Exception\PidBiggerMax;
13
use DanchukAS\DenyMultiplyRun\Exception\PidFileEmpty;
14
use DanchukAS\DenyMultiplyRun\Exception\ProcessExisted;
15
use DanchukAS\DenyMultiplyRun\Exception\ReadFileFail;
16
17
18
/**
19
 * Class denyMultiplyRun
20
 * Забороняє паралельний запуск скрипта
21
 *
22
 * @todo: extract work with file to another lib.
23
 *
24
 * @package DanchukAS\DenyMultiplyRun
25
 */
26
class DenyMultiplyRun
27
{
28
    /**
29
     * Для перехвата помилок що не кидають ексепшини.
30
     *
31
     * @var \Throwable
32
     */
33
    private static $lastError;
34
35
36
    /**
37
     * DenyMultiplyRun constructor.
38
     * Унеможливлює створення обєктів цього класу.
39
     * Даний клас лише для статичного визова методів.
40
     */
41
    private function __construct()
42
    {
43
    }
44
45
46
    /**
47
     * Унеможливлює паралельний запуск ще одного процеса pid-файл якого ідентичний.
48
     *
49
     * Створює файл в якому число-ідентифікатор процеса ОС під яким працює даний код.
50
     * Якщо файл існує і процеса з номером що в файлі записаний не існує -
51
     * пробує записати теперішній ідентифікатор процеса ОС під яким працює даний код.
52
     * В усіх інших випадках кидає відповідні виключення.
53
     *
54
     * @param string $pidFilePath Шлях до файла. Вимоги: користувач під яким запущений
55
     *                            даний код має мати право на створення, читання і зміну
56
     *                            даного файла.
57 8
     */
58
    public static function setPidFile(string $pidFilePath)
59 8
    {
60
        self::preparePidDir($pidFilePath);
61
62 8
        try {
63 2
            $file_resource = self::createPidFile($pidFilePath);
64 7
            $pid_file_existed = false;
65 7
        } catch (FileExisted $exception) {
66 7
            $file_resource = self::openPidFile($pidFilePath);
67
            $pid_file_existed = true;
68
        }
69 8
70
        self::lockPidFile($file_resource);
71
72
        try {
73 7
            if ($pid_file_existed) {
74 7
                try {
75
                    $prev_pid = self::getPidFromFile($file_resource);
76 6
                    self::checkRunnedPid($prev_pid);
77 3
                } catch (PidFileEmpty $exception) {
78 5
                    // if file was once empty is not critical.
79
                    // It was after crash daemon.
80 2
                    // There are signal for admin/developer.
81
                    trigger_error((string)$exception, E_USER_NOTICE);
82
                }
83 4
                self::truncatePidFile($file_resource);
84 4
            }
85
86 4
            $self_pid = getmypid();
87 2
            self::setPidIntoFile($self_pid, $file_resource);
88 1
89 2
            if ($pid_file_existed) {
90 2
                /** @noinspection PhpUndefinedVariableInspection */
91 2
                $message_reason = is_null($prev_pid)
1 ignored issue
show
Comprehensibility Best Practice introduced by
The variable $prev_pid does not seem to be defined for all execution paths leading up to this point.
Loading history...
92
                    ? ", but file empty."
93 2
                    : ", but process with contained ID($prev_pid) in it is not exist.";
94
                $message = "pid-file exist" . $message_reason
95 4
                    . " pid-file updated with pid this process: " . $self_pid;
96
97 7
                trigger_error($message, E_USER_NOTICE);
98 4
            }
99 7
        } finally {
100
            try {
101
                self::unlockPidFile($file_resource);
102
            } finally {
103 4
                self::closePidFile($file_resource);
104
            }
105
        }
106
    }
107
108
    /**
109
     * @param string $pidFilePath
110 8
     *
111
     * @throws \Exception
112 8
     */
113 8
    private static function preparePidDir($pidFilePath)
114
    {
115
        $pid_dir = dirname($pidFilePath);
116
117
        if ("" !== $pid_dir && !is_dir($pid_dir)) {
118
            $created_pid_dir = mkdir($pid_dir, 0777, true);
119 8
            if (false === $created_pid_dir) {
120
                throw new \Exception('Директорія відсутня і неможливо створити: ' . $pid_dir);
121
            }
122
        }
123
    }
124
125
    /**
126
     * @param string $pidFilePath
127 8
     * @return resource
128
     * @throws FileExisted
129
     * @throws \Exception
130
     */
131 8
    private static function createPidFile($pidFilePath)
132
    {
133
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
134
        // помилку в записує в self::$lastError
135 8
        self::startErrorHandle();
136
137
        // собачка потрібна щоб не засоряти логи.
138 8
        /** @noinspection PhpUsageOfSilenceOperatorInspection */
139
        $pid_file_handle = @fopen($pidFilePath, 'x');
140
141 8
        // Відновлюєм попередній обробник наче нічого і не робили.
142
        restore_error_handler();
143
144 7
        // файл не створений. сталась помилка
145
        if (!is_null(self::$lastError)) {
146
            // Файла і нема і не створився - повідомляєм про несправність проекта.
147
            if (!is_file($pidFilePath)) {
148
                throw new self::$lastError;
149 7
            }
150
151
            // Файл вже існує, тому не створився.
152
            throw new FileExisted($pidFilePath);
153 2
        }
154
155
        // файл створений успішно.
156
        return $pid_file_handle;
157
    }
158
159
    private static function startErrorHandle()
160
    {
161 7
        set_error_handler([__CLASS__, 'errorHandle']);
162
163 7
        self::$lastError = null;
164 7
    }
165
166
    /**
167 7
     * @param string $pidFilePath
168
     * @return resource
169
     * @throws \Exception
170
     */
171
    private static function openPidFile($pidFilePath)
172
    {
173
        self::startErrorHandle();
174
        try {
175 8
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
176
            $pid_file_handle = @fopen($pidFilePath, 'r+');
177 8
        } catch (\Throwable $error) {
178 8
            self::$lastError = $error;
179 1
        } finally {
180
            restore_error_handler();
181
        }
182
183 1
        if (!is_null(self::$lastError)) {
184
            throw new OpenFileFail((string) self::$lastError);
185
        }
186
187 1
        /** @noinspection PhpUndefinedVariableInspection */
188
        return $pid_file_handle;
189
    }
190 1
191
    /**
192 1
     * @param $pidFileResource
193
     *
194 7
     * @throws \Exception
195
     */
196
    private static function lockPidFile($pidFileResource)
197
    {
198
        $locked = flock($pidFileResource, LOCK_EX | LOCK_NB);
199
        if (false === $locked) {
200
            $error = self::$lastError;
201 6
202
            // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
203
            // помилку в записує в self::$lastError
204
            self::startErrorHandle();
205
206 6
            // собачка потрібна щоб не засоряти логи.
207
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
208
            $resource_data = @stream_get_meta_data($pidFileResource);
209 6
210
            // Відновлюєм попередній обробник наче нічого і не робили.
211
            restore_error_handler();
212
213 6
            throw new LockFileFail($resource_data['uri'] . ' - ' . $error);
214
        }
215 3
    }
216
217
    /**
218
     * @param resource $pidFileResource Дескриптор файла доступного для читання в якому знаходиться PID.
219
     * @return int PID з файла
220
     * @throws \Exception
221
     */
222
    private static function getPidFromFile($pidFileResource)
223
    {
224 6
        // Розмір PID (int в ОС) навряд буде більший ніж розмір int в PHP.
225
        // Зазвичай PID має до 5 цифр.
226
        // @todo: if error - warning, error_handler, ...
227 6
        $pid_from_file = fread($pidFileResource, 64);
228 1
229
230
        if (false === $pid_from_file) {
231 5
            throw new ReadFileFail("pid-файл є, але прочитати що в ньому не вдалось.");
232
        }
233
234
        $pid_int = self::validatePid($pid_from_file);
235
236 5
        return $pid_int;
237 5
    }
238 5
239 5
    /**
240 1
     * @param string $pid_from_file
241 1
     * @return int
242
     * @throws PidBiggerMax
243
     * @throws PidFileEmpty
244
     */
245
    private static function validatePid(string $pid_from_file): int
246
    {
247 4
        // На випадок коли станеться виліт скрипта після створення файла і до запису ІД.
248 1
        if ('' === $pid_from_file) {
249 1
            throw new PidFileEmpty();
250 1
        }
251
252
        $pid_int = (int) $pid_from_file;
253 3
254
        // verify available PID in file.
255
        // if PID not available - why it happens ?
256
        // For *nix system
257
        $pid_max_storage = "/proc/sys/kernel/pid_max";
258
        if (file_exists($pid_max_storage)) {
259
            $pid_max = (int) file_get_contents($pid_max_storage);
260
            if ($pid_max < $pid_int) {
261 3
                $message = "PID in file has unavailable value: $pid_int. In /proc/sys/kernel/pid_max set $pid_max.";
262
                throw new PidBiggerMax($message);
263
            }
264
        }
265
266
        // verify converting. (PHP_MAX_INT)
267 3
        // verify PID in file is right (something else instead ciphers).
268
        if ("{$pid_int}" !== $pid_from_file) {
269
            $message = "pid_int({$pid_int}) !== pid_string($pid_from_file)"
270
                . ", or pid_string($pid_from_file) is not Process ID)";
271 3
            throw new ConvertPidFail($message);
272
        }
273 2
274
        return $pid_int;
275 1
    }
276
277
    /**
278
     * @param int $pid
279
     *
280
     * @throws ProcessExisted
281
     */
282 2
    private static function checkRunnedPid($pid)
283
    {
284 2
        if (
285 2
            // Посилає сигнал процесу щоб дізнатись чи він існує.
286
            // Якщо true - точно існує.
287
            // якщо false - процес може і бути, але запущений під іншим користувачем або інші ситуації.
288
            true === posix_kill($pid, 0)
289 2
            // 3 = No such process (тестувалось в Ubuntu 14, FreeBSD 9. В інших ОС можуть відрізнятись)
290 2
            // Якщо процеса точно в даний момент нема такого.
291
            // Для визова цієї функції необхідний попередній визов posix_kill.
292
            || posix_get_last_error() !== 3
293 2
        ) {
294
            throw new ProcessExisted($pid);
295
        }
296
    }
297
298
    /**
299
     * @param $pidFileResource
300
     *
301 4
     * @throws \Exception
302
     */
303 4
    private static function truncatePidFile($pidFileResource)
304 4
    {
305 4
        $truncated = ftruncate($pidFileResource, 0);
306 4
        if (false === $truncated) {
307
            throw new \Exception("не вдалось очистити pid-файл.");
308
        }
309 4
310
        $cursor_to_begin = rewind($pidFileResource);
311
        if (!$cursor_to_begin) {
312
            throw new \Exception("не вдалось перемістити курсор на початок pid-файла.");
313
        }
314 7
    }
315
316 7
    /**
317 7
     * @param int $self_pid
318
     * @param $pidFileResource
319
     *
320 7
     * @throws \Exception
321
     */
322
    private static function setPidIntoFile($self_pid, $pidFileResource)
323
    {
324
        $self_pid = '' . $self_pid;
325
        $pid_length = strlen($self_pid);
326
        $write_length = fwrite($pidFileResource, $self_pid, $pid_length);
327 7
        if ($write_length !== $pid_length) {
328
            throw new \Exception("не вдось записати pid в pid-файл. Записано $write_length байт замість $pid_length");
329
        }
330
    }
331 7
332
    /**
333
     * @param $pidFileResource
334
     */
335 7
    private static function unlockPidFile($pidFileResource)
336
    {
337
        $unlocked = flock($pidFileResource, LOCK_UN | LOCK_NB);
338 7
        if (false === $unlocked) {
339
            trigger_error("не вдось розблокувати pid-файл.");
340 7
        }
341
    }
342
343
    /**
344
     * @param $pidFileResource
345
     *
346
     * @throws CloseFileFail
347
     */
348
    private static function closePidFile($pidFileResource)
349
    {
350
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
351
        // помилку в записує в self::$lastError
352
        self::startErrorHandle();
353
354
        try {
355
            // собачка потрібна щоб не засоряти логи.
356
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
357 7
            $closed = @fclose($pidFileResource);
358
        } catch (\Throwable $error) {
359
            $closed = false;
360
            self::$lastError = $error;
361
        } finally {
362
            // Відновлюєм попередній обробник наче нічого і не робили.
363
            restore_error_handler();
364
        }
365
366 3
        if (false === $closed) {
367
            $file_close_error = self::$lastError;
368
369
            // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
370 3
            // помилку в записує в self::$lastError
371
            self::startErrorHandle();
372
373
            try {
374
                // собачка потрібна щоб не засоряти логи.
375 3
                /** @noinspection PhpUsageOfSilenceOperatorInspection */
376
                $resource_data = @stream_get_meta_data($pidFileResource);
377
            } catch (\Throwable $error) {
378
                $resource_data = ['uri' => ''];
379
            } finally {
380 3
                // Відновлюєм попередній обробник наче нічого і не робили.
381
                restore_error_handler();
382 3
            }
383
384
            throw new CloseFileFail($resource_data['uri'], 457575, $file_close_error);
385 3
        }
386 1
    }
387
388
    /**
389
     * Відключає встановлену заборону паралельного запуска у яких спільний $pidFilePath
390 2
     * @todo добавити перевірку що цей файл ще для цього процеса,
391
     * може цей файл вже був видалений вручну, і створений іншим процесом.
392
     *
393
     * @param string $pidFilePath
394
     *
395
     * @throws DeleteFileFail
396
     */
397
    public static function deletePidFile($pidFilePath)
398
    {
399
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
400 9
        // помилку в записує в self::$lastError
401
        self::startErrorHandle();
402
403
        try {
404 9
            // собачка потрібна щоб не засоряти логи.
405
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
406 9
            @unlink($pidFilePath);
407
        } catch (\Throwable $error) {
408
            self::$lastError = $error;
409 9
        } finally {
410
            // Відновлюєм попередній обробник наче нічого і не робили.
411
            restore_error_handler();
412
        }
413
414
        if (!is_null(self::$lastError)) {
415
            throw new DeleteFileFail(self::$lastError);
416
        }
417
    }
418
419
    /**
420
     * @param int $messageType
421
     * @param string $messageText
422
     * @param string $messageFile
423
     * @param int $messageLine
424
     *
425
     * @return bool
426
     */
427
    public static function errorHandle(int $messageType, string $messageText, string $messageFile, int $messageLine)
428
    {
429
        // добавляємо лише інформацію яка є.
430
        // все інше добавляти має обробник самого проекта.
431
        $message = "[$messageType] $messageText in $messageFile on line $messageLine";
432
433
        self::$lastError = new \Exception($message);
434
435
        // Перехопити перехопили, кидаєм далі обробляти.
436
        return false;
437
    }
438
}
439
440