Completed
Push — develope ( aa1d2b...ffaac5 )
by Anatoliy
02:16
created

DenyMultiplyRun::pidFileUpdated()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2.1481

Importance

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