Passed
Push — develope ( ad001d...35de2e )
by Anatoliy
02:08
created

DenyMultiplyRun::setPidFile()   B

Complexity

Conditions 6
Paths 172

Size

Total Lines 46
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 6

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 46
rs 7.5748
cc 6
eloc 30
nc 172
nop 1
ccs 27
cts 27
cp 1
crap 6
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
    /**
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)
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
        self::pidIsNoEmpty($pid_from_file);
249 1
250 1
        $pid_int = (int) $pid_from_file;
251
252
        // verify converting. (PHP_MAX_INT)
253 3
        // verify PID in file is right (something else instead ciphers).
254
        if ("{$pid_int}" !== $pid_from_file) {
255
            $message = "pid_int({$pid_int}) !== pid_string($pid_from_file)"
256
                . ", or pid_string($pid_from_file) is not Process ID)";
257
            throw new ConvertPidFail($message);
258
        }
259
260
        self::pidIsPossible($pid_int);
261 3
262
        return $pid_int;
263
    }
264
265
    /**
266
     * @param string $pid
267 3
     * @throws PidFileEmpty
268
     */
269
    private static function pidIsNoEmpty(string $pid): void
270
    {
271 3
        if ('' === $pid) {
272
            throw new PidFileEmpty();
273 2
        }
274
    }
275 1
276
    /**
277
     * Verify possible value of PID in file: less than max possible on OS.
278
     * @param int $pid_int
279
     * @throws PidBiggerMax
280
     * @throws PidLessMin
281
     */
282 2
    private static function pidIsPossible($pid_int): void
283
    {
284 2
        if ($pid_int < 0) {
285 2
            $message = "PID in file has unavailable value: $pid_int. PID must be no negative.";
286
            throw new PidLessMin($message);
287
        }
288
289 2
        // if PID not available - why it happens ?
290 2
        // For *nix system
291
        $pid_max_storage = "/proc/sys/kernel/pid_max";
292
        if (file_exists($pid_max_storage)) {
293 2
            $pid_max = (int)file_get_contents($pid_max_storage);
294
            if ($pid_max < $pid_int) {
295
                $message = "PID in file has unavailable value: $pid_int. In /proc/sys/kernel/pid_max set $pid_max.";
296
                throw new PidBiggerMax($message);
297
            }
298
        }
299
    }
300
301 4
    /**
302
     * @param int $pid
303 4
     *
304 4
     * @throws ProcessExisted
305 4
     */
306 4
    private static function checkRunnedPid($pid)
307
    {
308
        if (// Посилає сигнал процесу щоб дізнатись чи він існує.
309 4
            // Якщо true - точно існує.
310
            // якщо false - процес може і бути, але запущений під іншим користувачем або інші ситуації.
311
            true === posix_kill($pid, 0)
312
            // 3 = No such process (тестувалось в Ubuntu 14, FreeBSD 9. В інших ОС можуть відрізнятись)
313
            // Якщо процеса точно в даний момент нема такого.
314 7
            // Для визова цієї функції необхідний попередній визов posix_kill.
315
            || posix_get_last_error() !== 3
316 7
        ) {
317 7
            throw new ProcessExisted($pid);
318
        }
319
    }
320 7
321
    /**
322
     * @param $pidFileResource
323
     *
324
     * @throws \Exception
325
     */
326
    private static function truncatePidFile($pidFileResource)
327 7
    {
328
        $truncated = ftruncate($pidFileResource, 0);
329
        if (false === $truncated) {
330
            throw new \Exception("не вдалось очистити pid-файл.");
331 7
        }
332
333
        $cursor_to_begin = rewind($pidFileResource);
334
        if (!$cursor_to_begin) {
335 7
            throw new \Exception("не вдалось перемістити курсор на початок pid-файла.");
336
        }
337
    }
338 7
339
    /**
340 7
     * @param int $self_pid
341
     * @param $pidFileResource
342
     *
343
     * @throws \Exception
344
     */
345
    private static function setPidIntoFile($self_pid, $pidFileResource)
346
    {
347
        $self_pid = '' . $self_pid;
348
        $pid_length = strlen($self_pid);
349
        $write_length = fwrite($pidFileResource, $self_pid, $pid_length);
350
        if ($write_length !== $pid_length) {
351
            throw new \Exception("не вдось записати pid в pid-файл. Записано $write_length байт замість $pid_length");
352
        }
353
    }
354
355
    /**
356
     * @param $pidFileResource
357 7
     */
358
    private static function unlockPidFile($pidFileResource)
359
    {
360
        $unlocked = flock($pidFileResource, LOCK_UN | LOCK_NB);
361
        if (false === $unlocked) {
362
            trigger_error("не вдось розблокувати pid-файл.");
363
        }
364
    }
365
366 3
    /**
367
     * @param $pidFileResource
368
     *
369
     * @throws CloseFileFail
370 3
     */
371
    private static function closePidFile($pidFileResource)
372
    {
373
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
374
        // помилку в записує в self::$lastError
375 3
        self::startErrorHandle();
376
377
        try {
378
            // собачка потрібна щоб не засоряти логи.
379
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
380 3
            $closed = @fclose($pidFileResource);
381
        } catch (\Throwable $error) {
382 3
            $closed = false;
383
            self::$lastError = $error;
384
        } finally {
385 3
            // Відновлюєм попередній обробник наче нічого і не робили.
386 1
            restore_error_handler();
387
        }
388
389
        if (false === $closed) {
390 2
            $file_close_error = self::$lastError;
391
392
            // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
393
            // помилку в записує в self::$lastError
394
            self::startErrorHandle();
395
396
            try {
397
                // собачка потрібна щоб не засоряти логи.
398
                /** @noinspection PhpUsageOfSilenceOperatorInspection */
399
                $resource_data = @stream_get_meta_data($pidFileResource);
400 9
            } catch (\Throwable $error) {
401
                $resource_data = ['uri' => ''];
402
            } finally {
403
                // Відновлюєм попередній обробник наче нічого і не робили.
404 9
                restore_error_handler();
405
            }
406 9
407
            throw new CloseFileFail($resource_data['uri'], 457575, $file_close_error);
408
        }
409 9
    }
410
411
    /**
412
     * Відключає встановлену заборону паралельного запуска у яких спільний $pidFilePath
413
     * @todo добавити перевірку що цей файл ще для цього процеса,
414
     * може цей файл вже був видалений вручну, і створений іншим процесом.
415
     *
416
     * @param string $pidFilePath
417
     *
418
     * @throws DeleteFileFail
419
     */
420
    public static function deletePidFile($pidFilePath)
421
    {
422
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
423
        // помилку в записує в self::$lastError
424
        self::startErrorHandle();
425
426
        try {
427
            // собачка потрібна щоб не засоряти логи.
428
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
429
            @unlink($pidFilePath);
430
        } catch (\Throwable $error) {
431
            self::$lastError = $error;
432
        } finally {
433
            // Відновлюєм попередній обробник наче нічого і не робили.
434
            restore_error_handler();
435
        }
436
437
        if (!is_null(self::$lastError)) {
438
            throw new DeleteFileFail(self::$lastError);
439
        }
440
    }
441
442
    /**
443
     * @param int $messageType
444
     * @param string $messageText
445
     * @param string $messageFile
446
     * @param int $messageLine
447
     *
448
     * @return bool
449
     */
450
    public static function errorHandle(int $messageType, string $messageText, string $messageFile, int $messageLine)
451
    {
452
        // добавляємо лише інформацію яка є.
453
        // все інше добавляти має обробник самого проекта.
454
        $message = "[$messageType] $messageText in $messageFile on line $messageLine";
455
456
        self::$lastError = new \Exception($message);
457
458
        // Перехопити перехопили, кидаєм далі обробляти.
459
        return false;
460
    }
461
}
462