DenyMultiplyRun::safeClosePidFile()   A
last analyzed

Complexity

Conditions 1
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 6
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 1
nc 2
nop 1
crap 1
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
     * @throws \Exception
63 2
     */
64 7
    public static function setPidFile(string $pidFilePath)
65 7
    {
66 7
        self::preparePidDir($pidFilePath);
67
68
        try {
69 8
            $file_resource = self::createPidFile($pidFilePath);
70
            $pid_file_existed = false;
71
        } catch (FileExisted $exception) {
72
            $file_resource = self::openPidFile($pidFilePath);
73 7
            $pid_file_existed = true;
74 7
        }
75
76 6
        self::lockPidFile($file_resource);
77 3
78 5
        try {
79
            self::safeSetPidIntoFile($pid_file_existed, $file_resource);
80 2
        } finally {
81
            self::safeClosePidFile($file_resource);
82
        }
83 4
    }
84 4
85
    /**
86 4
     * @param string $pidFilePath
87 2
     *
88 1
     * @throws \Exception
89 2
     */
90 2
    private static function preparePidDir($pidFilePath)
91 2
    {
92
        $pid_dir = \dirname($pidFilePath);
93 2
94
        /** @noinspection MkdirRaceConditionInspection */
95 4
        if ('' !== $pid_dir
96
            && !\is_dir($pid_dir)
97 7
            && !\mkdir($pid_dir, 0777, true)
98 4
        ) {
99 7
            throw new \RuntimeException('Директорія відсутня і неможливо створити: ' . $pid_dir);
100
        }
101
    }
102
103 4
    /**
104
     * @param string $pidFilePath
105
     * @return resource
106
     * @throws FileExisted
107
     * @throws \Exception
108
     */
109
    private static function createPidFile($pidFilePath)
110 8
    {
111
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
112 8
        // помилку в записує в self::$lastError
113 8
        self::startErrorHandle();
114
115
        // собачка потрібна щоб не засоряти логи.
116
        /** @noinspection PhpUsageOfSilenceOperatorInspection */
117
        $pid_file_handle = @fopen($pidFilePath, 'xb');
118
119 8
        // Відновлюєм попередній обробник наче нічого і не робили.
120
        restore_error_handler();
121
122
        // файл не створений. сталась помилка
123
        if (null !== self::$lastError) {
124
            self::createPidFileFailed($pidFilePath);
125
        }
126
127 8
        // файл створений успішно.
128
        return $pid_file_handle;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $pid_file_handle could also return false which is incompatible with the documented return type resource. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
129
    }
130
131 8
    private static function startErrorHandle()
132
    {
133
        set_error_handler([__CLASS__, 'errorHandle']);
134
135 8
        self::$lastError = null;
136
    }
137
138 8
    /**
139
     * @param $pidFilePath
140
     * @throws FileExisted
141 8
     */
142
    private static function createPidFileFailed($pidFilePath)
143
    {
144 7
// Файла і нема і не створився - повідомляєм про несправність проекта.
145
        if (!is_file($pidFilePath)) {
146
            throw new self::$lastError;
147
        }
148
149 7
        // Файл вже існує, тому не створився.
150
        throw new FileExisted($pidFilePath);
151
    }
152
153 2
    /**
154
     * @param string $pidFilePath
155
     * @return resource
156
     * @throws \Exception
157
     */
158
    private static function openPidFile($pidFilePath)
159
    {
160
        self::startErrorHandle();
161 7
        try {
162
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
163 7
            $pid_file_handle = @fopen($pidFilePath, 'rb+');
164 7
        } catch (\Throwable $error) {
165
            self::$lastError = $error;
166
        } finally {
167 7
            restore_error_handler();
168
        }
169
170
        if (null !== self::$lastError) {
171
            throw new OpenFileFail((string) self::$lastError);
172
        }
173
174
        /** @noinspection PhpUndefinedVariableInspection */
175 8
        return $pid_file_handle;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $pid_file_handle could also return false which is incompatible with the documented return type resource. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
176
    }
177 8
178 8
    /**
179 1
     * @param $pidFileResource
180
     *
181
     * @throws \Exception
182
     */
183 1
    private static function lockPidFile($pidFileResource)
184
    {
185
        $locked = flock($pidFileResource, LOCK_EX | LOCK_NB);
186
        if (false === $locked) {
187 1
            $error = self::$lastError;
188
189
            // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
190 1
            // помилку в записує в self::$lastError
191
            self::startErrorHandle();
192 1
193
            // собачка потрібна щоб не засоряти логи.
194 7
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
195
            $resource_data = @stream_get_meta_data($pidFileResource);
196
197
            // Відновлюєм попередній обробник наче нічого і не робили.
198
            restore_error_handler();
199
200
            throw new LockFileFail($resource_data['uri'] . ' - ' . $error);
201 6
        }
202
    }
203
204
    /**
205
     * @param $pid_file_existed
206 6
     * @param $file_resource
207
     * @throws \Exception
208
     */
209 6
    private static function safeSetPidIntoFile($pid_file_existed, $file_resource)
210
    {
211
        if ($pid_file_existed) {
212
            self::pidNotActual($file_resource);
213 6
        }
214
215 3
        $self_pid = getmypid();
216
        self::setPidIntoFile($self_pid, $file_resource);
217
218
        if ($pid_file_existed) {
219
            self::pidFileUpdated($self_pid);
220
        }
221
    }
222
223
    /**
224 6
     * @param $file_resource
225
     * @throws \Exception
226
     * @throws \DanchukAS\DenyMultiplyRun\Exception\ProcessExisted
227 6
     */
228 1
    private static function pidNotActual($file_resource)
229
    {
230
        self::$prevPid = null;
231 5
232
        try {
233
            self::$prevPid = self::getPidFromFile($file_resource);
234
            self::pidNoExisting(self::$prevPid);
235
        } catch (PidFileEmpty $exception) {
236 5
            // if file was once empty is not critical.
237 5
            // It was after crash daemon.
238 5
            // There are signal for admin/developer.
239 5
            trigger_error((string)$exception);
240 1
        }
241 1
        self::truncatePidFile($file_resource);
242
    }
243
244
    /**
245
     * @param resource $pidFileResource Дескриптор файла доступного для читання в якому знаходиться PID.
246
     * @return int PID з файла
247 4
     * @throws \Exception
248 1
     */
249 1
    private static function getPidFromFile($pidFileResource)
250 1
    {
251
        // Розмір PID (int в ОС) навряд буде більший ніж розмір int в PHP.
252
        // Зазвичай PID має до 5 цифр.
253 3
        // @todo: if error - warning, error_handler, ...
254
        $pid_from_file = fread($pidFileResource, 64);
255
256
257
        if (false === $pid_from_file) {
258
            throw new ReadFileFail('pid-файл є, але прочитати що в ньому не вдалось.');
259
        }
260
261 3
        return self::validatePid($pid_from_file);
262
    }
263
264
    /**
265
     * @param string $pid_from_file
266
     * @return int
267 3
     * @throws \DanchukAS\DenyMultiplyRun\Exception\ConvertPidFail
268
     * @throws \DanchukAS\DenyMultiplyRun\Exception\PidLessMin
269
     * @throws PidBiggerMax
270
     * @throws PidFileEmpty
271 3
     */
272
    private static function validatePid(string $pid_from_file): int
273 2
    {
274
        // На випадок коли станеться виліт скрипта після створення файла і до запису ІД.
275 1
        self::pidIsNoEmpty($pid_from_file);
276
277
        $pid_int = (int) $pid_from_file;
278
279
        // verify converting. (PHP_MAX_INT)
280
        // verify PID in file is right (something else instead ciphers).
281
        if ("{$pid_int}" !== $pid_from_file) {
282 2
            $message = "pid_int({$pid_int}) !== pid_string($pid_from_file)"
283
                . ", or pid_string($pid_from_file) is not Process ID)";
284 2
            throw new ConvertPidFail($message);
285 2
        }
286
287
        self::pidIsPossible($pid_int);
288
289 2
        return $pid_int;
290 2
    }
291
292
    /**
293 2
     * @param string $pid
294
     * @throws PidFileEmpty
295
     */
296
    private static function pidIsNoEmpty(string $pid)
297
    {
298
        if ('' === $pid) {
299
            throw new PidFileEmpty();
300
        }
301 4
    }
302
303 4
    /**
304 4
     * Verify possible value of PID in file: less than max possible on OS.
305 4
     * @param int $pid_int
306 4
     * @throws PidBiggerMax
307
     * @throws PidLessMin
308
     */
309 4
    private static function pidIsPossible($pid_int)
310
    {
311
        if ($pid_int < 0) {
312
            $message = "PID in file has unavailable value: $pid_int. PID must be no negative.";
313
            throw new PidLessMin($message);
314 7
        }
315
316 7
        // if PID not available - why it happens ?
317 7
        // For *nix system
318
        $pid_max_storage = '/proc/sys/kernel/pid_max';
319
        if (file_exists($pid_max_storage)) {
320 7
            $pid_max = (int)file_get_contents($pid_max_storage);
321
            if ($pid_max < $pid_int) {
322
                $message = "PID in file has unavailable value: $pid_int. In /proc/sys/kernel/pid_max set $pid_max.";
323
                throw new PidBiggerMax($message);
324
            }
325
        }
326
    }
327 7
328
    /**
329
     * @param int $pid
330
     *
331 7
     * @throws ProcessExisted
332
     */
333
    private static function pidNoExisting($pid)
334
    {
335 7
        if (// Посилає сигнал процесу щоб дізнатись чи він існує.
336
            // Якщо true - точно існує.
337
            // якщо false - процес може і бути, але запущений під іншим користувачем або інші ситуації.
338 7
            true === posix_kill($pid, 0)
339
            // 3 = No such process (тестувалось в Ubuntu 14, FreeBSD 9. В інших ОС можуть відрізнятись)
340 7
            // Якщо процеса точно в даний момент нема такого.
341
            // Для визова цієї функції необхідний попередній визов posix_kill.
342
            || posix_get_last_error() !== 3
343
        ) {
344
            throw new ProcessExisted($pid);
345
        }
346
    }
347
348
    /**
349
     * @param $pidFileResource
350
     *
351
     * @throws \Exception
352
     */
353
    private static function truncatePidFile($pidFileResource)
354
    {
355
        $truncated = ftruncate($pidFileResource, 0);
356
        if (false === $truncated) {
357 7
            throw new \RuntimeException('не вдалось очистити pid-файл.');
358
        }
359
360
        $cursor_to_begin = rewind($pidFileResource);
361
        if (!$cursor_to_begin) {
362
            throw new \RuntimeException('не вдалось перемістити курсор на початок pid-файла.');
363
        }
364
    }
365
366 3
    /**
367
     * @param int $self_pid
368
     * @param $pidFileResource
369
     *
370 3
     * @throws \Exception
371
     */
372
    private static function setPidIntoFile($self_pid, $pidFileResource)
373
    {
374
        $self_pid_str = (string)$self_pid;
375 3
        $pid_length = strlen($self_pid_str);
376
        $write_length = fwrite($pidFileResource, $self_pid_str, $pid_length);
377
        if ($write_length !== $pid_length) {
378
            throw new \RuntimeException("не вдось записати pid в pid-файл. Записано $write_length байт замість $pid_length");
379
        }
380 3
    }
381
382 3
    /**
383
     * @param $self_pid
384
     */
385 3
    private static function pidFileUpdated($self_pid)
386 1
    {
387
        $message_reason = null === self::$prevPid
0 ignored issues
show
introduced by
The condition self::prevPid is always false. If self::prevPid can have other possible types, add them to src/DenyMultiplyRun.php:36
Loading history...
388
            ? ', but file empty.'
389
            : ', but process with contained ID(' . self::$prevPid . ') in it is not exist.';
390 2
        $message = 'pid-file exist' . $message_reason
391
            . ' pid-file updated with pid this process: ' . $self_pid;
392
393
        trigger_error($message);
394
    }
395
396
    /**
397
     * @param $file_resource
398
     * @throws \DanchukAS\DenyMultiplyRun\Exception\CloseFileFail
399
     */
400 9
    private static function safeClosePidFile($file_resource)
401
    {
402
        try {
403
            self::unlockPidFile($file_resource);
404 9
        } finally {
405
            self::closePidFile($file_resource);
406 9
        }
407
    }
408
409 9
    /**
410
     * @param $pidFileResource
411
     */
412
    private static function unlockPidFile($pidFileResource)
413
    {
414
        $unlocked = flock($pidFileResource, LOCK_UN | LOCK_NB);
415
        if (false === $unlocked) {
416
            trigger_error('не вдось розблокувати pid-файл.');
417
        }
418
    }
419
420
    /**
421
     * @param $pidFileResource
422
     *
423
     * @throws CloseFileFail
424
     */
425
    private static function closePidFile($pidFileResource)
426
    {
427
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
428
        // помилку в записує в self::$lastError
429
        self::startErrorHandle();
430
431
        try {
432
            // собачка потрібна щоб не засоряти логи.
433
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
434
            $closed = @fclose($pidFileResource);
435
        } catch (\Throwable $error) {
436
            $closed = false;
437
            self::$lastError = $error;
438
        } finally {
439
            // Відновлюєм попередній обробник наче нічого і не робили.
440
            restore_error_handler();
441
        }
442
443
        if (false === $closed) {
444
            self::closePidFileFailed($pidFileResource);
445
        }
446
    }
447
448
    /**
449
     * @param $pidFileResource
450
     * @throws CloseFileFail
451
     */
452
    private static function closePidFileFailed($pidFileResource)
453
    {
454
        $file_close_error = self::$lastError;
455
456
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
457
        // помилку в записує в self::$lastError
458
        self::startErrorHandle();
459
460
        try {
461
            // собачка потрібна щоб не засоряти логи.
462
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
463
            $resource_data = @stream_get_meta_data($pidFileResource);
464
        } catch (\Throwable $error) {
465
            $resource_data = ['uri' => ''];
466
        } finally {
467
            // Відновлюєм попередній обробник наче нічого і не робили.
468
            restore_error_handler();
469
        }
470
471
        throw new CloseFileFail($resource_data['uri'], 457575, $file_close_error);
472
    }
473
474
    /**
475
     * Відключає встановлену заборону паралельного запуска у яких спільний $pidFilePath
476
     * @todo добавити перевірку що цей файл ще для цього процеса,
477
     * може цей файл вже був видалений вручну, і створений іншим процесом.
478
     *
479
     * @param string $pidFilePath
480
     *
481
     * @throws DeleteFileFail
482
     */
483
    public static function deletePidFile($pidFilePath)
484
    {
485
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
486
        // помилку в записує в self::$lastError
487
        self::startErrorHandle();
488
489
        try {
490
            // собачка потрібна щоб не засоряти логи.
491
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
492
            @unlink($pidFilePath);
493
        } catch (\Throwable $error) {
494
            self::$lastError = $error;
495
        } finally {
496
            // Відновлюєм попередній обробник наче нічого і не робили.
497
            restore_error_handler();
498
        }
499
500
        if (null !== self::$lastError) {
501
            throw new DeleteFileFail(self::$lastError);
502
        }
503
    }
504
505
    /** @noinspection MoreThanThreeArgumentsInspection */
506
    /**
507
     * @param int $messageType
508
     * @param string $messageText
509
     * @param string $messageFile
510
     * @param int $messageLine
511
     *
512
     * @return bool
513
     */
514
    public static function errorHandle(int $messageType, string $messageText, string $messageFile, int $messageLine)
515
    {
516
        // добавляємо лише інформацію яка є.
517
        // все інше добавляти має обробник самого проекта.
518
        $message = "[$messageType] $messageText in $messageFile on line $messageLine";
519
520
        self::$lastError = new \Exception($message);
521
522
        // Перехопити перехопили, кидаєм далі обробляти.
523
        return false;
524
    }
525
}
526