Issues (13)

src/DenyMultiplyRun.php (1 issue)

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;
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;
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
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