Completed
Pull Request — master (#20)
by Anatoliy
18:45 queued 08:46
created

DenyMultiplyRun::setPidFile()   A

Complexity

Conditions 2
Paths 4

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

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