Test Failed
Push — master ( f56a84...3fd10f )
by Anatoliy
01:08
created

DenyMultiplyRun::truncatePidFile()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3.0416

Importance

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