Completed
Push — master ( dc1dc7...cc32cb )
by Anatoliy
04:10 queued 01:51
created

DenyMultiplyRun::truncatePidFile()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3.0261

Importance

Changes 0
Metric Value
dl 0
loc 10
c 0
b 0
f 0
ccs 6
cts 7
cp 0.8571
rs 9.4285
cc 3
eloc 6
nc 1
nop 1
crap 3.0261
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
 * Class denyMultiplyRun
19
 * Забороняє паралельний запуск скрипта
20
 *
21
 * @todo: extract work with file to another lib.
22
 *
23
 * @package DanchukAS\DenyMultiplyRun
24
 */
25
class DenyMultiplyRun
26
{
27
    /**
28
     * Для перехвата помилок що не кидають ексепшини.
29
     *
30
     * @var \Throwable
31
     */
32
    private static $lastError;
33
34
35
    /**
36
     * DenyMultiplyRun constructor.
37
     * Унеможливлює створення обєктів цього класу.
38
     * Даний клас лише для статичного визова методів.
39
     */
40
    private function __construct()
41
    {
42
    }
43
44
45
    /**
46
     * Унеможливлює паралельний запуск ще одного процеса pid-файл якого ідентичний.
47
     *
48
     * Створює файл в якому число-ідентифікатор процеса ОС під яким працює даний код.
49
     * Якщо файл існує і процеса з номером що в файлі записаний не існує -
50
     * пробує записати теперішній ідентифікатор процеса ОС під яким працює даний код.
51
     * В усіх інших випадках кидає відповідні виключення.
52
     *
53
     * @param string $pidFilePath Шлях до файла. Вимоги: користувач під яким запущений
54
     *                            даний код має мати право на створення, читання і зміну
55
     *                            даного файла.
56
     */
57 8
    public static function setPidFile(string $pidFilePath)
58
    {
59 8
        self::preparePidDir($pidFilePath);
60
61
        try {
62 8
            $file_resource = self::createPidFile($pidFilePath);
63 2
            $pid_file_existed = false;
64 7
        } catch (FileExisted $exception) {
65 7
            $file_resource = self::openPidFile($pidFilePath);
66 7
            $pid_file_existed = true;
67
        }
68
69 8
        self::lockPidFile($file_resource);
70
71
        try {
72
            if ($pid_file_existed) {
73 7
                try {
74 7
                    $prev_pid = self::getPidFromFile($file_resource);
75
                    self::checkRunnedPid($prev_pid);
76 6
                } catch (PidFileEmpty $exception) {
77 3
                    // if file was once empty is not critical.
78 5
                    // It was after crash daemon.
79
                    // There are signal for admin/developer.
80 2
                    trigger_error((string)$exception, E_USER_NOTICE);
81
                }
82
                self::truncatePidFile($file_resource);
83 4
            }
84 4
85
            $self_pid = getmypid();
86 4
            self::setPidIntoFile($self_pid, $file_resource);
87 2
88 1
            if ($pid_file_existed) {
89 2
                /** @noinspection PhpUndefinedVariableInspection */
90 2
                $message_reason = is_null($prev_pid)
91 2
                    ? ", but file empty."
92
                    : ", but process with contained ID($prev_pid) in it is not exist.";
93 2
                $message = "pid-file exist" . $message_reason
94
                    . " pid-file updated with pid this process: " . $self_pid;
95 4
96
                trigger_error($message, E_USER_NOTICE);
97 7
            }
98 4
        } finally {
99 7
            try {
100
                self::unlockPidFile($file_resource);
101
            } finally {
102
                self::closePidFile($file_resource);
103 4
            }
104
        }
105
    }
106
107
    /**
108
     * @param string $pidFilePath
109
     *
110 8
     * @throws \Exception
111
     */
112 8
    private static function preparePidDir($pidFilePath)
113 8
    {
114
        $pid_dir = dirname($pidFilePath);
115
116
        if ("" !== $pid_dir && !is_dir($pid_dir)) {
117
            $created_pid_dir = mkdir($pid_dir, 0777, true);
118
            if (false === $created_pid_dir) {
119 8
                throw new \Exception('Директорія відсутня і неможливо створити: ' . $pid_dir);
120
            }
121
        }
122
    }
123
124
    /**
125
     * @param string $pidFilePath
126
     * @return resource
127 8
     * @throws FileExisted
128
     * @throws \Exception
129
     */
130
    private static function createPidFile($pidFilePath)
131 8
    {
132
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
133
        // помилку в записує в self::$lastError
134
        self::startErrorHandle();
135 8
136
        // собачка потрібна щоб не засоряти логи.
137
        /** @noinspection PhpUsageOfSilenceOperatorInspection */
138 8
        $pid_file_handle = @fopen($pidFilePath, 'x');
139
140
        // Відновлюєм попередній обробник наче нічого і не робили.
141 8
        restore_error_handler();
142
143
        // файл не створений. сталась помилка
144 7
        if (!is_null(self::$lastError)) {
145
            // Файла і нема і не створився - повідомляєм про несправність проекта.
146
            if (!is_file($pidFilePath)) {
147
                throw new self::$lastError;
148
            }
149 7
150
            // Файл вже існує, тому не створився.
151
            throw new FileExisted($pidFilePath);
152
        }
153 2
154
        // файл створений успішно.
155
        return $pid_file_handle;
156
    }
157
158
    private static function startErrorHandle()
159
    {
160
        set_error_handler([__CLASS__, 'errorHandle']);
161 7
162
        self::$lastError = null;
163 7
    }
164 7
165
    /**
166
     * @param string $pidFilePath
167 7
     * @return resource
168
     * @throws \Exception
169
     */
170
    private static function openPidFile($pidFilePath)
171
    {
172
        self::startErrorHandle();
173
        try {
174
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
175 8
            $pid_file_handle = @fopen($pidFilePath, 'r+');
176
        } catch (\Throwable $error) {
177 8
            self::$lastError = $error;
178 8
        } finally {
179 1
            restore_error_handler();
180
        }
181
182
        if (!is_null(self::$lastError)) {
183 1
            throw new OpenFileFail((string) self::$lastError);
184
        }
185
186
        /** @noinspection PhpUndefinedVariableInspection */
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 resource $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
            // Якщо true - точно існує.
285 2
            // якщо false - процес може і бути, але запущений під іншим користувачем або інші ситуації.
286
            true === posix_kill($pid, 0)
287
            // 3 = No such process (тестувалось в Ubuntu 14, FreeBSD 9. В інших ОС можуть відрізнятись)
288
            // Якщо процеса точно в даний момент нема такого.
289 2
            // Для визова цієї функції необхідний попередній визов posix_kill.
290 2
            || posix_get_last_error() !== 3
291
        ) {
292
            throw new ProcessExisted($pid);
293 2
        }
294
    }
295
296
    /**
297
     * @param $pidFileResource
298
     *
299
     * @throws \Exception
300
     */
301 4
    private static function truncatePidFile($pidFileResource)
302
    {
303 4
        $truncated = ftruncate($pidFileResource, 0);
304 4
        if (false === $truncated) {
305 4
            throw new \Exception("не вдалось очистити pid-файл.");
306 4
        }
307
308
        $cursor_to_begin = rewind($pidFileResource);
309 4
        if (!$cursor_to_begin) {
310
            throw new \Exception("не вдалось перемістити курсор на початок pid-файла.");
311
        }
312
    }
313
314 7
    /**
315
     * @param int $self_pid
316 7
     * @param $pidFileResource
317 7
     *
318
     * @throws \Exception
319
     */
320 7
    private static function setPidIntoFile($self_pid, $pidFileResource)
321
    {
322
        $self_pid = '' . $self_pid;
323
        $pid_length = strlen($self_pid);
324
        $write_length = fwrite($pidFileResource, $self_pid, $pid_length);
325
        if ($write_length !== $pid_length) {
326
            throw new \Exception("не вдось записати pid в pid-файл. Записано $write_length байт замість $pid_length");
327 7
        }
328
    }
329
330
    /**
331 7
     * @param $pidFileResource
332
     */
333
    private static function unlockPidFile($pidFileResource)
334
    {
335 7
        $unlocked = flock($pidFileResource, LOCK_UN | LOCK_NB);
336
        if (false === $unlocked) {
337
            trigger_error("не вдось розблокувати pid-файл.");
338 7
        }
339
    }
340 7
341
    /**
342
     * @param $pidFileResource
343
     *
344
     * @throws CloseFileFail
345
     */
346
    private static function closePidFile($pidFileResource)
347
    {
348
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
349
        // помилку в записує в self::$lastError
350
        self::startErrorHandle();
351
352
        try {
353
            // собачка потрібна щоб не засоряти логи.
354
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
355
            $closed = @fclose($pidFileResource);
356
        } catch (\Throwable $error) {
357 7
            $closed = false;
358
            self::$lastError = $error;
359
        } finally {
360
            // Відновлюєм попередній обробник наче нічого і не робили.
361
            restore_error_handler();
362
        }
363
364
        if (false === $closed) {
365
            $file_close_error = self::$lastError;
366 3
367
            // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
368
            // помилку в записує в self::$lastError
369
            self::startErrorHandle();
370 3
371
            try {
372
                // собачка потрібна щоб не засоряти логи.
373
                /** @noinspection PhpUsageOfSilenceOperatorInspection */
374
                $resource_data = @stream_get_meta_data($pidFileResource);
375 3
            } catch (\Throwable $error) {
376
                $resource_data = ['uri' => ''];
377
            } finally {
378
                // Відновлюєм попередній обробник наче нічого і не робили.
379
                restore_error_handler();
380 3
            }
381
382 3
            throw new CloseFileFail($resource_data['uri'], 457575, $file_close_error);
383
        }
384
    }
385 3
386 1
    /**
387
     * Відключає встановлену заборону паралельного запуска у яких спільний $pidFilePath
388
     * @todo добавити перевірку що цей файл ще для цього процеса,
389
     * може цей файл вже був видалений вручну, і створений іншим процесом.
390 2
     *
391
     * @param string $pidFilePath
392
     *
393
     * @throws DeleteFileFail
394
     */
395
    public static function deletePidFile($pidFilePath)
396
    {
397
        // перехоплювач на 1 команду, щоб в разі потреби потім дізнатись причину несправності.
398
        // помилку в записує в self::$lastError
399
        self::startErrorHandle();
400 9
401
        try {
402
            // собачка потрібна щоб не засоряти логи.
403
            /** @noinspection PhpUsageOfSilenceOperatorInspection */
404 9
            @unlink($pidFilePath);
405
        } catch (\Throwable $error) {
406 9
            self::$lastError = $error;
407
        } finally {
408
            // Відновлюєм попередній обробник наче нічого і не робили.
409 9
            restore_error_handler();
410
        }
411
412
        if (!is_null(self::$lastError)) {
413
            throw new DeleteFileFail(self::$lastError);
414
        }
415
    }
416
417
    /**
418
     * @param int $messageType
419
     * @param string $messageText
420
     * @param string $messageFile
421
     * @param int $messageLine
422
     *
423
     * @return bool
424
     */
425
    public static function errorHandle(int $messageType, string $messageText, string $messageFile, int $messageLine)
426
    {
427
        // добавляємо лише інформацію яка є.
428
        // все інше добавляти має обробник самого проекта.
429
        $message = "[$messageType] $messageText in $messageFile on line $messageLine";
430
431
        self::$lastError = new \Exception($message);
432
433
        // Перехопити перехопили, кидаєм далі обробляти.
434
        return false;
435
    }
436
}
437