Passed
Push — develop ( 2a83e2...38cefb )
by Nikolay
05:24
created

Processes::handleWorkerAction()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 33
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
c 0
b 0
f 0
dl 0
loc 33
rs 9.584
cc 4
nc 4
nop 8

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/*
4
 * MikoPBX - free phone system for small business
5
 * Copyright © 2017-2023 Alexey Portnov and Nikolay Beketov
6
 *
7
 * This program is free software: you can redistribute it and/or modify
8
 * it under the terms of the GNU General Public License as published by
9
 * the Free Software Foundation; either version 3 of the License, or
10
 * (at your option) any later version.
11
 *
12
 * This program is distributed in the hope that it will be useful,
13
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 * GNU General Public License for more details.
16
 *
17
 * You should have received a copy of the GNU General Public License along with this program.
18
 * If not, see <https://www.gnu.org/licenses/>.
19
 */
20
21
namespace MikoPBX\Core\System;
22
23
use InvalidArgumentException;
24
use MikoPBX\Core\Workers\Cron\WorkerSafeScriptsCore;
25
use Phalcon\Di\Di;
26
use RuntimeException;
27
use Throwable;
28
29
/**
30
 * Class Processes
31
 *
32
 * Manages system and PHP processes with enhanced safety and reliability.
33
 *
34
 * @package MikoPBX\Core\System
35
 */
36
class Processes
37
{
38
    /**
39
     * Default timeout for background process execution in seconds
40
    */
41
    private const DEFAULT_BG_TIMEOUT = 4;
42
43
    /**
44
     * Default number of attempts for safe daemon start
45
     */
46
    private const DEFAULT_START_ATTEMPTS = 20;
47
48
    /**
49
     * Default timeout between attempts in microseconds
50
     */
51
    private const DEFAULT_ATTEMPT_TIMEOUT = 1000000;
52
53
    /**
54
     * Default output file for process logs
55
     */
56
    private const DEFAULT_OUTPUT_FILE = '/dev/null';
57
58
    /**
59
     * Directory for temporary shell scripts
60
     */
61
    private const TEMP_SCRIPTS_DIR = '/tmp';
62
63
    /**
64
     * Valid process actions
65
     */
66
    private const VALID_ACTIONS = ['start', 'stop', 'restart', 'status'];
67
68
    /**
69
     * Validates process action.
70
     *
71
     * @param string $action Action to validate
72
     * @return bool
73
     */
74
    private static function isValidAction(string $action): bool
75
    {
76
        return in_array($action, self::VALID_ACTIONS, true);
77
    }
78
79
    /**
80
     * Checks if a process is running.
81
     *
82
     * @param string $processIdOrName Process ID or name
83
     * @return bool
84
     */
85
    public static function isProcessRunning(string $processIdOrName): bool
86
    {
87
        if (is_numeric($processIdOrName)) {
88
            // Check by PID
89
            return file_exists("/proc/$processIdOrName");
90
        }
91
        // Check by process name
92
        return self::getPidOfProcess($processIdOrName) !== '';
93
    }
94
95
    /**
96
     * Safely terminates a process with timeout.
97
     *
98
     * @param string $pid Process ID to terminate
99
     * @param int $timeout Timeout in seconds
100
     * @return bool Success status
101
     */
102
    private static function safeTerminateProcess(string $pid, int $timeout = 10): bool
103
    {
104
        if (empty($pid)) {
105
            return false;
106
        }
107
108
        $kill = Util::which('kill');
109
110
        // First try SIGTERM
111
        self::mwExec("$kill -SIGTERM $pid");
112
113
        // Wait for process to terminate
114
        $startTime = time();
115
        while (time() - $startTime < $timeout) {
116
            if (!self::isProcessRunning($pid)) {
117
                return true;
118
            }
119
            usleep(100000); // 100ms
120
        }
121
122
        // Force kill if still running
123
        self::mwExec("$kill -9 $pid");
124
        return !self::isProcessRunning($pid);
125
    }
126
127
128
    /**
129
     * Waits for a process to start.
130
     *
131
     * @param string $procName Process name
132
     * @param string $excludeName Process name to exclude
133
     * @param int $attempts Maximum number of attempts
134
     * @param int $timeout Timeout between attempts
135
     * @return string Process ID or empty string
136
     */
137
    private static function waitForProcessStart(
138
        string $procName,
139
        string $excludeName,
140
        int $attempts,
141
        int $timeout
142
    ): string {
143
        $attempt = 1;
144
        while ($attempt < $attempts) {
145
            $pid = self::getPidOfProcess($procName, $excludeName);
146
            if (!empty($pid)) {
147
                return $pid;
148
            }
149
            usleep($timeout);
150
            $attempt++;
151
        }
152
        return '';
153
    }
154
155
    /**
156
     * Kills a process/daemon by name.
157
     *
158
     * @param string $procName The name of the process/daemon to kill.
159
     * @return int|null The return code of the execution.
160
     */
161
    public static function killByName(string $procName): ?int
162
    {
163
        if (empty(trim($procName))) {
164
            throw new InvalidArgumentException('Process name cannot be empty');
165
        }
166
167
        $killallPath = Util::which('killall');
168
        return self::mwExec($killallPath . ' ' . escapeshellarg($procName));
169
    }
170
171
    /**
172
     * Executes a command using exec().
173
     *
174
     * @param string $command The command to execute.
175
     * @param array|null $outArr Reference to array for command output.
176
     * @param int|null $retVal Reference for return value.
177
     * @return int The return value of the execution.
178
     * @throws RuntimeException If command is empty.
179
     */
180
    public static function mwExec(string $command, ?array &$outArr = null, ?int &$retVal = null): int
181
    {
182
        if (empty(trim($command))) {
183
            throw new RuntimeException('Empty command provided to mwExec');
184
        }
185
186
        $retVal = 0;
187
        $outArr = [];
188
        $di = Di::getDefault();
189
190
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
191
            echo "mwExec(): $command\n";
192
        } else {
193
            exec("$command 2>&1", $outArr, $retVal);
194
        }
195
        return $retVal;
196
    }
197
198
    /**
199
     * Executes a command as a background process with timeout.
200
     *
201
     * @param string $command The command to execute.
202
     * @param int $timeout The timeout value in seconds.
203
     * @param string $logName The name of the log file.
204
     */
205
    public static function mwExecBgWithTimeout(
206
        string $command,
207
        int $timeout = self::DEFAULT_BG_TIMEOUT,
0 ignored issues
show
Unused Code introduced by
The parameter $timeout is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

207
        /** @scrutinizer ignore-unused */ int $timeout = self::DEFAULT_BG_TIMEOUT,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
208
        string $logName = self::DEFAULT_OUTPUT_FILE
209
    ): void {
210
        $di = Di::getDefault();
211
212
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
213
            echo "mwExecBg(): $command\n";
214
            return;
215
        }
216
217
        $nohup = Util::which('nohup');
218
        $timeout = Util::which('timeout');
219
        exec("$nohup $timeout $timeout $command > $logName 2>&1 &");
220
    }
221
222
    /**
223
     * Executes multiple commands sequentially.
224
     *
225
     * @param array $arrCmds The array of commands to execute.
226
     * @param array|null $out Reference to array for output.
227
     * @param string $logname The log file name.
228
     */
229
    public static function mwExecCommands(array $arrCmds, ?array &$out = [], string $logname = ''): void
230
    {
231
        $out = [];
232
        foreach ($arrCmds as $cmd) {
233
            $out[] = "$cmd;";
234
            $outCmd = [];
235
            self::mwExec($cmd, $outCmd);
236
            $out = array_merge($out, $outCmd);
0 ignored issues
show
Bug introduced by
It seems like $outCmd can also be of type null; however, parameter $arrays of array_merge() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

236
            $out = array_merge($out, /** @scrutinizer ignore-type */ $outCmd);
Loading history...
237
        }
238
239
        if ($logname !== '') {
240
            $result = implode("\n", $out);
241
            file_put_contents(
242
                self::TEMP_SCRIPTS_DIR . "/{$logname}_commands.log",
243
                $result
244
            );
245
        }
246
    }
247
248
    /**
249
     * Restarts all workers in a separate process.
250
     */
251
    public static function restartAllWorkers(): void
252
    {
253
        $workerSafeScriptsPath = Util::getFilePathByClassName(WorkerSafeScriptsCore::class);
254
        $php = Util::which('php');
255
        $workerSafeScripts = "$php -f $workerSafeScriptsPath restart > /dev/null 2> /dev/null";
256
        self::mwExec($workerSafeScripts);
257
        SystemMessages::sysLogMsg(
258
            static::class,
259
            "Service asked for WorkerSafeScriptsCore restart",
260
            LOG_DEBUG
261
        );
262
    }
263
264
    /**
265
     * Manages a PHP worker process.
266
     *
267
     * @param string $className The class name of the PHP worker.
268
     * @param string $paramForPHPWorker The parameter for the PHP worker.
269
     * @param string $action The action to perform.
270
     */
271
    public static function processPHPWorker(
272
        string $className,
273
        string $paramForPHPWorker = 'start',
274
        string $action = 'restart'
275
    ): void {
276
        if (!self::isValidAction($action)) {
277
            throw new InvalidArgumentException("Invalid action: $action");
278
        }
279
280
        SystemMessages::sysLogMsg(
281
            __METHOD__,
282
            "processPHPWorker $className action-$action",
283
            LOG_DEBUG
284
        );
285
286
        $workerPath = Util::getFilePathByClassName($className);
287
        if (empty($workerPath) || !class_exists($className)) {
288
            return;
289
        }
290
291
        $php = Util::which('php');
292
        $command = "$php -f $workerPath";
293
        $kill = Util::which('kill');
294
295
        $activeProcesses = self::getPidOfProcess($className);
296
        $processes = array_filter(explode(' ', $activeProcesses));
297
        $currentProcCount = count($processes);
298
299
        $workerObject = new $className();
300
        $neededProcCount = $workerObject->maxProc;
301
302
        self::handleWorkerAction(
303
            $action,
304
            $activeProcesses,
305
            $command,
306
            $paramForPHPWorker,
307
            $kill,
308
            $currentProcCount,
309
            $neededProcCount,
310
            $processes
311
        );
312
    }
313
314
    /**
315
     * Handles worker process actions.
316
     *
317
     * @param string $action Action to perform
318
     * @param string $activeProcesses Active process IDs
319
     * @param string $command Command to execute
320
     * @param string $paramForPHPWorker Worker parameters
321
     * @param string $kill Kill command path
322
     * @param int $currentProcCount Current process count
323
     * @param int $neededProcCount Needed process count
324
     * @param array $processes Process IDs array
325
     */
326
    private static function handleWorkerAction(
327
        string $action,
328
        string $activeProcesses,
329
        string $command,
330
        string $paramForPHPWorker,
331
        string $kill,
332
        int $currentProcCount,
333
        int $neededProcCount,
334
        array $processes
335
    ): void {
336
        switch ($action) {
337
            case 'restart':
338
                self::handleRestartAction(
339
                    $activeProcesses,
340
                    $kill,
341
                    $command,
342
                    $paramForPHPWorker,
343
                    $neededProcCount
344
                );
345
                break;
346
            case 'stop':
347
                self::handleStopAction($activeProcesses, $kill);
348
                break;
349
            case 'start':
350
                self::handleStartAction(
351
                    $currentProcCount,
352
                    $neededProcCount,
353
                    $command,
354
                    $paramForPHPWorker,
355
                    $processes,
356
                    $kill
357
                );
358
                break;
359
        }
360
    }
361
362
    /**
363
     * Handles the restart action for a worker.
364
     *
365
     * @param string $activeProcesses Active process IDs
366
     * @param string $kill Kill command path
367
     * @param string $command Command to execute
368
     * @param string $paramForPHPWorker Worker parameters
369
     * @param int $neededProcCount Needed process count
370
     */
371
    private static function handleRestartAction(
372
        string $activeProcesses,
373
        string $kill,
374
        string $command,
375
        string $paramForPHPWorker,
376
        int $neededProcCount
377
    ): void {
378
        if ($activeProcesses !== '') {
379
            self::mwExec("$kill -SIGUSR1 $activeProcesses > /dev/null 2>&1 &");
380
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
381
        }
382
383
        for ($i = 0; $i < $neededProcCount; $i++) {
384
            self::mwExecBg("$command $paramForPHPWorker");
385
        }
386
    }
387
388
    /**
389
     * Handles the stop action for a worker.
390
     *
391
     * @param string $activeProcesses Active process IDs
392
     * @param string $kill Kill command path
393
     */
394
    private static function handleStopAction(string $activeProcesses, string $kill): void
395
    {
396
        if ($activeProcesses !== '') {
397
            self::mwExec("$kill -SIGUSR2 $activeProcesses > /dev/null 2>&1 &");
398
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
399
        }
400
    }
401
402
    /**
403
     * Handles the start action for a worker.
404
     *
405
     * @param int $currentProcCount Current process count
406
     * @param int $neededProcCount Needed process count
407
     * @param string $command Command to execute
408
     * @param string $paramForPHPWorker Worker parameters
409
     * @param array $processes Process IDs array
410
     * @param string $kill Kill command path
411
     */
412
    private static function handleStartAction(
413
        int $currentProcCount,
414
        int $neededProcCount,
415
        string $command,
416
        string $paramForPHPWorker,
417
        array $processes,
418
        string $kill
419
    ): void {
420
        if ($currentProcCount === $neededProcCount) {
421
            return;
422
        }
423
424
        if ($neededProcCount > $currentProcCount) {
425
            for ($i = $currentProcCount; $i < $neededProcCount; $i++) {
426
                self::mwExecBg("$command $paramForPHPWorker");
427
            }
428
        } elseif ($currentProcCount > $neededProcCount) {
429
            $countProc4Kill = $currentProcCount - $neededProcCount;
430
            for ($i = 0; $i < $countProc4Kill; $i++) {
431
                if (!isset($processes[$i])) {
432
                    break;
433
                }
434
                self::mwExec("$kill -SIGUSR1 {$processes[$i]} > /dev/null 2>&1 &");
435
                self::mwExecBg("$kill -SIGTERM {$processes[$i]}");
436
            }
437
        }
438
    }
439
440
    /**
441
     * Retrieves the PID of a process by its name.
442
     *
443
     * @param string $name Process name
444
     * @param string $exclude Process name to exclude
445
     * @return string Process IDs
446
     */
447
    public static function getPidOfProcess(string $name, string $exclude = ''): string
448
    {
449
        $ps = Util::which('ps');
450
        $grep = Util::which('grep');
451
        $awk = Util::which('awk');
452
453
        $name = addslashes($name);
454
        $filterCmd = '';
455
        if (!empty($exclude)) {
456
            $filterCmd = "| $grep -v " . escapeshellarg($exclude);
457
        }
458
459
        $out = [];
460
        self::mwExec(
461
            "$ps -A -o 'pid,args' $filterCmd | $grep '$name' | $grep -v grep | $awk '{print $1}'",
462
            $out
463
        );
464
465
        return trim(implode(' ', $out));
0 ignored issues
show
Bug introduced by
It seems like $out can also be of type null; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

465
        return trim(implode(' ', /** @scrutinizer ignore-type */ $out));
Loading history...
466
    }
467
468
    /**
469
     * Executes a command as a background process.
470
     *
471
     * @param string $command Command to execute
472
     * @param string $outFile Output file path
473
     * @param int $sleepTime Sleep time in seconds
474
     */
475
    public static function mwExecBg(
476
        string $command,
477
        string $outFile = self::DEFAULT_OUTPUT_FILE,
478
        int $sleepTime = 0
479
    ): void {
480
        $nohup = Util::which('nohup');
481
        $sh = Util::which('sh');
482
        $rm = Util::which('rm');
483
        $sleep = Util::which('sleep');
484
485
        if ($sleepTime > 0) {
486
            $filename = self::TEMP_SCRIPTS_DIR . '/' . time() . '_noop.sh';
487
            file_put_contents(
488
                $filename,
489
                "$sleep $sleepTime; $command; $rm -rf $filename"
490
            );
491
            $noopCommand = "$nohup $sh $filename > $outFile 2>&1 &";
492
        } else {
493
            $noopCommand = "$nohup $command > $outFile 2>&1 &";
494
        }
495
        exec($noopCommand);
496
    }
497
498
    /**
499
     * Manages a daemon/worker process.
500
     *
501
     * @param string $cmd Command to execute
502
     * @param string $param Command parameters
503
     * @param string $procName Process name
504
     * @param string $action Action to perform
505
     * @param string $outFile Output file path
506
     *
507
     * @return array|bool Process status or operation result
508
     * @throws InvalidArgumentException If action is invalid
509
     */
510
    public static function processWorker(
511
        string $cmd,
512
        string $param,
513
        string $procName,
514
        string $action,
515
        string $outFile = self::DEFAULT_OUTPUT_FILE
516
    ): bool|array {
517
        if (!self::isValidAction($action)) {
518
            throw new InvalidArgumentException("Invalid action: $action");
519
        }
520
521
        $kill = Util::which('kill');
522
        $nohup = Util::which('nohup');
523
        $workerPID = self::getPidOfProcess($procName);
524
525
        return match ($action) {
526
            'status' => [
527
                'status' => ($workerPID !== '') ? 'Started' : 'Stopped',
528
                'app' => $procName,
529
                'PID' => $workerPID
530
            ],
531
            'restart' => self::handleWorkerRestart($kill, $workerPID, $nohup, $cmd, $param, $outFile),
532
            'stop' => self::handleWorkerStop($kill, $workerPID),
533
            'start' => self::handleWorkerStart($workerPID, $nohup, $cmd, $param, $outFile),
534
            default => throw new InvalidArgumentException("Unsupported action: $action"),
535
        };
536
    }
537
538
    /**
539
     * Handles worker restart operation.
540
     *
541
     * @param string $kill Kill command path
542
     * @param string $workerPID Worker process ID
543
     * @param string $nohup Nohup command path
544
     * @param string $cmd Command to execute
545
     * @param string $param Command parameters
546
     * @param string $outFile Output file path
547
     * @return bool
548
     */
549
    private static function handleWorkerRestart(
550
        string $kill,
0 ignored issues
show
Unused Code introduced by
The parameter $kill is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

550
        /** @scrutinizer ignore-unused */ string $kill,

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
551
        string $workerPID,
552
        string $nohup,
553
        string $cmd,
554
        string $param,
555
        string $outFile
556
    ): bool {
557
        if ($workerPID !== '') {
558
            self::safeTerminateProcess($workerPID);
559
        }
560
        self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
561
        return true;
562
    }
563
564
    /**
565
     * Handles worker stop operation.
566
     *
567
     * @param string $kill Kill command path
568
     * @param string $workerPID Worker process ID
569
     * @return bool
570
     */
571
    private static function handleWorkerStop(string $kill, string $workerPID): bool
0 ignored issues
show
Unused Code introduced by
The parameter $kill is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

571
    private static function handleWorkerStop(/** @scrutinizer ignore-unused */ string $kill, string $workerPID): bool

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
572
    {
573
        if ($workerPID !== '') {
574
            self::safeTerminateProcess($workerPID);
575
        }
576
        return true;
577
    }
578
579
    /**
580
     * Handles worker start operation.
581
     *
582
     * @param string $workerPID Worker process ID
583
     * @param string $nohup Nohup command path
584
     * @param string $cmd Command to execute
585
     * @param string $param Command parameters
586
     * @param string $outFile Output file path
587
     * @return bool
588
     */
589
    private static function handleWorkerStart(
590
        string $workerPID,
591
        string $nohup,
592
        string $cmd,
593
        string $param,
594
        string $outFile
595
    ): bool {
596
        if ($workerPID === '') {
597
            self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
598
        }
599
        return true;
600
    }
601
602
    /**
603
     * Starts a daemon process with failure control.
604
     *
605
     * @param string $procName Process name
606
     * @param string $args Process arguments
607
     * @param int $attemptsCount Number of attempts to start
608
     * @param int $timeout Timeout between attempts in microseconds
609
     * @param string $outFile Output file path
610
     * @return bool Success status
611
     */
612
    public static function safeStartDaemon(
613
        string $procName,
614
        string $args,
615
        int $attemptsCount = self::DEFAULT_START_ATTEMPTS,
616
        int $timeout = self::DEFAULT_ATTEMPT_TIMEOUT,
617
        string $outFile = self::DEFAULT_OUTPUT_FILE
618
    ): bool {
619
        $result = true;
620
        $baseName = "safe-$procName";
621
        $safeLink = "/sbin/$baseName";
622
623
        try {
624
            Util::createUpdateSymlink('/etc/rc/worker_reload', $safeLink);
625
            self::killByName($baseName);
626
            self::killByName($procName);
627
628
            // Start the process in the background
629
            self::mwExecBg("$safeLink $args", $outFile);
630
631
            // Wait for the process to start with timeout
632
            $pid = self::waitForProcessStart($procName, $baseName, $attemptsCount, $timeout);
633
634
            if (empty($pid)) {
635
                SystemMessages::echoWithSyslog(" - Wait for start '$procName' fail" . PHP_EOL);
636
                $result = false;
637
            }
638
        } catch (Throwable $e) {
639
            SystemMessages::echoWithSyslog("Error starting daemon '$procName': " . $e->getMessage());
640
            $result = false;
641
        }
642
643
        return $result;
644
    }
645
646
}
647