Processes::mwExec()   A
last analyzed

Complexity

Conditions 4
Paths 3

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 10
c 0
b 0
f 0
dl 0
loc 16
rs 9.9332
cc 4
nc 3
nop 3
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
    /**
70
     * Directory for process lock files
71
     */
72
    private const LOCK_FILE_DIR = '/var/run/php-workers';
73
    private const PID_FILE_SUFFIX = '.pid';
74
75
    /**
76
     * Cleans up stale PID files in the workers directory
77
     * 
78
     * @return void
79
     */
80
    private static function cleanupStalePidFiles(): void
81
    {
82
        if (!is_dir(self::LOCK_FILE_DIR)) {
83
            return;
84
        }
85
86
        $files = glob(self::LOCK_FILE_DIR . '/*' . self::PID_FILE_SUFFIX . '*');
87
        foreach ($files as $file) {
88
            // Read PID from file
89
            $pid = @file_get_contents($file);
90
            if ($pid === false) {
91
                @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

91
                /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
92
                continue;
93
            }
94
95
            // Check if process is still running
96
            if (!self::isProcessRunning($pid)) {
97
                @unlink($file);
98
            }
99
        }
100
    }
101
102
    /**
103
     * Gets the path to the PID file for a given class name
104
     *
105
     * @param string $className The class name
106
     * @param int $instanceNum The instance number (for workers with multiple instances)
107
     * @return string Path to the PID file
108
     */
109
    public static function getPidFilePath(string $className, int $instanceNum = 1): string
110
    {
111
        // Ensure PID directory exists
112
        Util::mwMkdir(self::LOCK_FILE_DIR, true);
113
        $name = str_replace('\\', '-', $className);
114
        if ($instanceNum > 1) {
115
            return self::LOCK_FILE_DIR . "/$name-$instanceNum" . self::PID_FILE_SUFFIX;
116
        }
117
        return self::LOCK_FILE_DIR . "/$name" . self::PID_FILE_SUFFIX;
118
    }
119
120
    /**
121
     * Gets the path to the PID file for a forked process
122
     *
123
     * @param string $className The class name
124
     * @param int $pid Process ID
125
     * @param int $instanceNum The instance number
126
     * @return string Path to the forked process PID file
127
     */
128
    public static function getForkedPidFilePath(string $className, int $pid, int $instanceNum = 1): string
129
    {
130
        $basePidFile = self::getPidFilePath($className, $instanceNum);
131
        return sprintf('%s.%d', $basePidFile, $pid);
132
    }
133
134
    /**
135
     * Saves a PID to a file with proper locking
136
     *
137
     * @param string $pidFile Path to the PID file
138
     * @param int $pid Process ID to save
139
     * @throws RuntimeException If unable to write PID file
140
     */
141
    public static function savePidFile(string $pidFile, int $pid): void
142
    {
143
        try {
144
            $pidDir = dirname($pidFile);
145
146
            // Ensure PID directory exists
147
            if (!is_dir($pidDir) && !mkdir($pidDir, 0755, true)) {
148
                throw new RuntimeException("Could not create PID directory: $pidDir");
149
            }
150
151
            // Use exclusive file creation to avoid race conditions
152
            $handle = fopen($pidFile, 'c+');
153
            if ($handle === false) {
154
                throw new RuntimeException("Could not open PID file: $pidFile");
155
            }
156
157
            if (!flock($handle, LOCK_EX | LOCK_NB)) {
158
                fclose($handle);
159
                throw new RuntimeException("Could not acquire lock on PID file: $pidFile");
160
            }
161
162
            // Write PID to file
163
            if (ftruncate($handle, 0) === false || fwrite($handle, (string)$pid) === false) {
164
                flock($handle, LOCK_UN);
165
                fclose($handle);
166
                throw new RuntimeException("Could not write to PID file: $pidFile");
167
            }
168
169
            flock($handle, LOCK_UN);
170
            fclose($handle);
171
172
        } catch (Throwable $e) {
173
            SystemMessages::sysLogMsg(
174
                __CLASS__,
175
                "Failed to save PID file: " . $e->getMessage(),
176
                LOG_WARNING
177
            );
178
            throw new RuntimeException('PID file operation failed', 0, $e);
179
        }
180
    }
181
182
    /**
183
     * Validates process action.
184
     *
185
     * @param string $action Action to validate
186
     * @return bool
187
     */
188
    private static function isValidAction(string $action): bool
189
    {
190
        return in_array($action, self::VALID_ACTIONS, true);
191
    }
192
193
    /**
194
     * Checks if a process is running.
195
     *
196
     * @param string $processIdOrName Process ID or name
197
     * @return bool
198
     */
199
    public static function isProcessRunning(string $processIdOrName): bool
200
    {
201
        if (is_numeric($processIdOrName)) {
202
            // Check by PID
203
            return file_exists("/proc/$processIdOrName");
204
        }
205
        // Check by process name
206
        return self::getPidOfProcess($processIdOrName) !== '';
207
    }
208
209
    /**
210
     * Safely terminates a process with timeout.
211
     *
212
     * @param string $pid Process ID to terminate
213
     * @param int $timeout Timeout in seconds
214
     * @return bool Success status
215
     */
216
    private static function safeTerminateProcess(string $pid, int $timeout = 10): bool
217
    {
218
        if (empty($pid)) {
219
            return false;
220
        }
221
222
        $kill = Util::which('kill');
223
224
        // First try SIGTERM
225
        self::mwExec("$kill -SIGTERM $pid");
226
227
        // Wait for process to terminate
228
        $startTime = time();
229
        while (time() - $startTime < $timeout) {
230
            if (!self::isProcessRunning($pid)) {
231
                return true;
232
            }
233
            usleep(100000); // 100ms
234
        }
235
236
        // Force kill if still running
237
        self::mwExec("$kill -9 $pid");
238
        return !self::isProcessRunning($pid);
239
    }
240
241
242
    /**
243
     * Waits for a process to start.
244
     *
245
     * @param string $procName Process name
246
     * @param string $excludeName Process name to exclude
247
     * @param int $attempts Maximum number of attempts
248
     * @param int $timeout Timeout between attempts
249
     * @return string Process ID or empty string
250
     */
251
    private static function waitForProcessStart(
252
        string $procName,
253
        string $excludeName,
254
        int $attempts,
255
        int $timeout
256
    ): string {
257
        $attempt = 1;
258
        while ($attempt < $attempts) {
259
            $pid = self::getPidOfProcess($procName, $excludeName);
260
            if (!empty($pid)) {
261
                return $pid;
262
            }
263
            usleep($timeout);
264
            $attempt++;
265
        }
266
        return '';
267
    }
268
269
    /**
270
     * Kills a process/daemon by name.
271
     *
272
     * @param string $procName The name of the process/daemon to kill.
273
     * @return int|null The return code of the execution.
274
     */
275
    public static function killByName(string $procName): ?int
276
    {
277
        if (empty(trim($procName))) {
278
            throw new InvalidArgumentException('Process name cannot be empty');
279
        }
280
281
        $killallPath = Util::which('killall');
282
        return self::mwExec($killallPath . ' ' . escapeshellarg($procName));
283
    }
284
285
    /**
286
     * Executes a command using exec().
287
     *
288
     * @param string $command The command to execute.
289
     * @param array|null $outArr Reference to array for command output.
290
     * @param int|null $retVal Reference for return value.
291
     * @return int The return value of the execution.
292
     * @throws RuntimeException If command is empty.
293
     */
294
    public static function mwExec(string $command, ?array &$outArr = null, ?int &$retVal = null): int
295
    {
296
        if (empty(trim($command))) {
297
            throw new RuntimeException('Empty command provided to mwExec');
298
        }
299
300
        $retVal = 0;
301
        $outArr = [];
302
        $di = Di::getDefault();
303
304
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
305
            echo "mwExec(): $command\n";
306
        } else {
307
            exec("$command 2>&1", $outArr, $retVal);
308
        }
309
        return $retVal;
310
    }
311
312
    /**
313
     * Executes a command as a background process with timeout.
314
     *
315
     * @param string $command The command to execute.
316
     * @param int $timeout The timeout value in seconds.
317
     * @param string $logName The name of the log file.
318
     */
319
    public static function mwExecBgWithTimeout(
320
        string $command,
321
        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

321
        /** @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...
322
        string $logName = self::DEFAULT_OUTPUT_FILE
323
    ): void {
324
        $di = Di::getDefault();
325
326
        if ($di !== null && $di->getShared('config')->path('core.debugMode')) {
327
            echo "mwExecBg(): $command\n";
328
            return;
329
        }
330
331
        $nohup = Util::which('nohup');
332
        $timeout = Util::which('timeout');
333
        exec("$nohup $timeout $timeout $command > $logName 2>&1 &");
334
    }
335
336
    /**
337
     * Executes multiple commands sequentially.
338
     *
339
     * @param array $arrCmds The array of commands to execute.
340
     * @param array|null $out Reference to array for output.
341
     * @param string $logname The log file name.
342
     */
343
    public static function mwExecCommands(array $arrCmds, ?array &$out = [], string $logname = ''): void
344
    {
345
        $out = [];
346
        foreach ($arrCmds as $cmd) {
347
            $out[] = "$cmd;";
348
            $outCmd = [];
349
            self::mwExec($cmd, $outCmd);
350
            $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

350
            $out = array_merge($out, /** @scrutinizer ignore-type */ $outCmd);
Loading history...
351
        }
352
353
        if ($logname !== '') {
354
            $result = implode("\n", $out);
355
            file_put_contents(
356
                self::TEMP_SCRIPTS_DIR . "/{$logname}_commands.log",
357
                $result
358
            );
359
        }
360
    }
361
362
    /**
363
     * Restarts all workers in a separate process with improved pipeline.
364
     * 
365
     * The restart process:
366
     * 1. Starts WorkerSafeScriptsCore with restart parameter
367
     * 2. WorkerSafeScriptsCore collects all workers and their forks
368
     * 3. Starts new instances of each worker
369
     * 4. Gracefully shuts down old workers
370
     * 
371
     */
372
    public static function restartAllWorkers(): void
373
    {
374
        $workerSafeScriptsPath = Util::getFilePathByClassName(WorkerSafeScriptsCore::class);
375
        $php = Util::which('php');
376
                
377
        // First restart WorkerSafeScriptsCore itself
378
        // This will trigger the full restart pipeline
379
        $workerSafeScripts = "$php -f $workerSafeScriptsPath restart > /dev/null 2> /dev/null";
380
        self::mwExec($workerSafeScripts);
381
    }
382
383
    /**
384
     * Manages a PHP worker process.
385
     *
386
     * @param string $className The class name of the PHP worker.
387
     * @param string $paramForPHPWorker The parameter for the PHP worker.
388
     * @param string $action The action to perform.
389
     */
390
    public static function processPHPWorker(
391
        string $className,
392
        string $paramForPHPWorker = 'start',
393
        string $action = 'restart'
394
    ): void {
395
        // Clean up stale PID files before managing workers
396
        self::cleanupStalePidFiles();
397
398
        if (!self::isValidAction($action)) {
399
            throw new InvalidArgumentException("Invalid action: $action");
400
        }
401
402
        SystemMessages::sysLogMsg(
403
            __METHOD__,
404
            "processPHPWorker $className action-$action",
405
            LOG_DEBUG
406
        );
407
408
        $workerPath = Util::getFilePathByClassName($className);
409
        if (empty($workerPath) || !class_exists($className)) {
410
            return;
411
        }
412
413
        $php = Util::which('php');
414
        $command = "$php -f $workerPath";
415
        $kill = Util::which('kill');
416
417
        $activeProcesses = self::getPidOfProcess($className);
418
        $processes = array_filter(explode(' ', $activeProcesses));
419
        $currentProcCount = count($processes);
420
        
421
        // Определяем количество нужных экземпляров из maxProc класса
422
        $neededProcCount = 1; // По умолчанию 1 процесс
423
        
424
        if (class_exists($className)) {
425
            // Получаем значение maxProc из класса
426
            $reflectionClass = new \ReflectionClass($className);
427
            if ($reflectionClass->hasProperty('maxProc')) {
428
                $defaultProperties = $reflectionClass->getDefaultProperties();
429
                if (isset($defaultProperties['maxProc'])) {
430
                    $neededProcCount = (int)$defaultProperties['maxProc'];
431
                }
432
            }
433
        }
434
435
        self::handleWorkerAction(
436
            $action,
437
            $activeProcesses,
438
            $command,
439
            $paramForPHPWorker,
440
            $kill,
441
            $currentProcCount,
442
            $neededProcCount,
443
            $processes,
444
            $className
445
        );
446
    }
447
448
    /**
449
     * Handles worker process actions.
450
     *
451
     * @param string $action Action to perform
452
     * @param string $activeProcesses Active process IDs
453
     * @param string $command Command to execute
454
     * @param string $paramForPHPWorker Worker parameters
455
     * @param string $kill Kill command path
456
     * @param int $currentProcCount Current process count
457
     * @param int $neededProcCount Needed process count
458
     * @param array $processes Process IDs array
459
     * @param string $className Worker class name
460
     */
461
    private static function handleWorkerAction(
462
        string $action,
463
        string $activeProcesses,
464
        string $command,
465
        string $paramForPHPWorker,
466
        string $kill,
467
        int $currentProcCount,
468
        int $neededProcCount,
469
        array $processes,
470
        string $className = ''
471
    ): void {
472
        switch ($action) {
473
            case 'restart':
474
                self::handleRestartAction(
475
                    $activeProcesses,
476
                    $kill,
477
                    $command,
478
                    $paramForPHPWorker,
479
                    $className,
480
                    $neededProcCount
481
                );
482
                break;
483
            case 'stop':
484
                self::handleStopAction($activeProcesses, $kill);
485
                break;
486
            case 'start':
487
                self::handleStartAction(
488
                    $currentProcCount,
489
                    $neededProcCount,
490
                    $command,
491
                    $paramForPHPWorker,
492
                    $processes,
493
                    $kill
494
                );
495
                break;
496
        }
497
    }
498
499
    /**
500
     * Handles the restart action for a worker.
501
     *
502
     * @param string $activeProcesses Active process IDs
503
     * @param string $kill Kill command path
504
     * @param string $command Command to execute
505
     * @param string $paramForPHPWorker Worker parameters
506
     * @param string $workerClass Worker class name
507
     * @param int $neededProcCount Number of instances needed
508
     */
509
    private static function handleRestartAction(
510
        string $activeProcesses,
511
        string $kill,
512
        string $command,
513
        string $paramForPHPWorker,
514
        string $workerClass = '',
515
        int $neededProcCount = 1
516
    ): void {
517
        // Отладочное логирование
518
        SystemMessages::sysLogMsg(
519
            __METHOD__,
520
            sprintf(
521
                "Restarting worker: %s, neededProcCount=%d, activeProcesses=%s",
522
                $workerClass,
523
                $neededProcCount,
524
                $activeProcesses
525
            ),
526
            LOG_NOTICE
527
        );
528
529
        if ($activeProcesses !== '') {
530
            SystemMessages::sysLogMsg(
531
                __METHOD__,
532
                sprintf("SEND signal: %s", "SIGUSR1 $activeProcesses"),
533
                LOG_WARNING
534
            );
535
536
            // Send SIGUSR1 first to initiate graceful shutdown
537
            self::mwExec("$kill -SIGUSR1 $activeProcesses > /dev/null 2>&1");
538
            
539
            // Wait for 2 seconds to allow worker to handle SIGUSR1
540
            sleep(2);
541
542
            SystemMessages::sysLogMsg(
543
                __METHOD__,
544
                sprintf("SEND signal: %s", "SIGTERM $activeProcesses"),
545
                LOG_WARNING
546
            );
547
548
            // Then send SIGTERM
549
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
550
        }
551
552
        // Запуск воркеров с поддержкой пула
553
        if ($neededProcCount > 1) {
554
            SystemMessages::sysLogMsg(
555
                __METHOD__,
556
                sprintf("Starting worker pool with %d instances for %s", $neededProcCount, $workerClass),
557
                LOG_NOTICE
558
            );
559
            
560
            // Запускаем необходимое количество экземпляров
561
            for ($i = 1; $i <= $neededProcCount; $i++) {
562
                $instanceParam = " --instance-id={$i}";
563
                
564
                SystemMessages::sysLogMsg(
565
                    __METHOD__,
566
                    sprintf("Starting worker instance %d/%d: %s", $i, $neededProcCount, $command . " " . $paramForPHPWorker . $instanceParam),
567
                    LOG_DEBUG
568
                );
569
                
570
                self::mwExecBg("{$command} {$paramForPHPWorker}{$instanceParam}");
571
                
572
                // Небольшая задержка между запусками экземпляров для предотвращения конфликтов
573
                usleep(250000); // 250ms
574
            }
575
        } else {
576
            // Start new instance (for non-pool workers)
577
            self::mwExecBg("$command $paramForPHPWorker");
578
        }
579
    }
580
581
    /**
582
     * Handles the stop action for a worker.
583
     *
584
     * @param string $activeProcesses Active process IDs
585
     * @param string $kill Kill command path
586
     */
587
    private static function handleStopAction(string $activeProcesses, string $kill): void
588
    {
589
        if ($activeProcesses !== '') {
590
            self::mwExec("$kill -SIGUSR2 $activeProcesses > /dev/null 2>&1 &");
591
            self::mwExecBg("$kill -SIGTERM $activeProcesses");
592
        }
593
    }
594
595
    /**
596
     * Handles the start action for a worker.
597
     *
598
     * @param int $currentProcCount Current process count
599
     * @param int $neededProcCount Needed process count
600
     * @param string $command Command to execute
601
     * @param string $paramForPHPWorker Worker parameters
602
     * @param array $processes Process IDs array
603
     * @param string $kill Kill command path
604
     */
605
    private static function handleStartAction(
606
        int $currentProcCount,
607
        int $neededProcCount,
608
        string $command,
609
        string $paramForPHPWorker,
610
        array $processes,
611
        string $kill
612
    ): void {
613
        if ($currentProcCount === $neededProcCount) {
614
            return;
615
        }
616
617
        // Добавляем отладочное логирование
618
        SystemMessages::sysLogMsg(
619
            __METHOD__,
620
            sprintf(
621
                "Starting worker pool: currentCount=%d, neededCount=%d, command=%s",
622
                $currentProcCount,
623
                $neededProcCount,
624
                $command
625
            ),
626
            LOG_DEBUG
627
        );
628
629
        if ($neededProcCount > $currentProcCount) {
630
            // Запускаем новые экземпляры с указанием instance ID
631
            for ($i = 0; $i < $neededProcCount; $i++) {
632
                $instanceParam = '';
633
                // Если мы запускаем больше одного экземпляра, добавляем параметр instanceId
634
                if ($neededProcCount > 1) {
635
                    $instanceId = $i + 1; // Нумеруем с 1
636
                    $instanceParam = " --instance-id={$instanceId}";
637
                }
638
                
639
                // Отладочное логирование
640
                SystemMessages::sysLogMsg(
641
                    __METHOD__,
642
                    sprintf(
643
                        "Launching instance #%d with command: %s %s%s",
644
                        ($i+1),
645
                        $command, 
646
                        $paramForPHPWorker,
647
                        $instanceParam
648
                    ),
649
                    LOG_DEBUG
650
                );
651
                
652
                self::mwExecBg("{$command} {$paramForPHPWorker}{$instanceParam}");
653
            }
654
        } elseif ($currentProcCount > $neededProcCount) {
655
            $countProc4Kill = $currentProcCount - $neededProcCount;
656
            for ($i = 0; $i < $countProc4Kill; $i++) {
657
                if (!isset($processes[$i])) {
658
                    break;
659
                }
660
                self::mwExec("$kill -SIGUSR1 {$processes[$i]} > /dev/null 2>&1 &");
661
                self::mwExecBg("$kill -SIGTERM {$processes[$i]}");
662
            }
663
        }
664
    }
665
666
    /**
667
     * Retrieves the PID of a process by its name.
668
     *
669
     * @param string $name Process name
670
     * @param string $exclude Process name to exclude
671
     * @return string Process IDs
672
     */
673
    public static function getPidOfProcess(string $name, string $exclude = ''): string
674
    {
675
        $ps = Util::which('ps');
676
        $grep = Util::which('grep');
677
        $awk = Util::which('awk');
678
679
        $name = addslashes($name);
680
        $filterCmd = '';
681
        if (!empty($exclude)) {
682
            $filterCmd = "| $grep -v " . escapeshellarg($exclude);
683
        }
684
685
        $out = [];
686
        self::mwExec(
687
            "$ps -A -o 'pid,args' $filterCmd | $grep '$name' | $grep -v grep | $awk '{print $1}'",
688
            $out
689
        );
690
691
        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

691
        return trim(implode(' ', /** @scrutinizer ignore-type */ $out));
Loading history...
692
    }
693
694
    /**
695
     * Executes a command as a background process.
696
     *
697
     * @param string $command Command to execute
698
     * @param string $outFile Output file path
699
     * @param int $sleepTime Sleep time in seconds
700
     */
701
    public static function mwExecBg(
702
        string $command,
703
        string $outFile = self::DEFAULT_OUTPUT_FILE,
704
        int $sleepTime = 0
705
    ): void {
706
        $nohup = Util::which('nohup');
707
        $sh = Util::which('sh');
708
        $rm = Util::which('rm');
709
        $sleep = Util::which('sleep');
710
711
        if ($sleepTime > 0) {
712
            $filename = self::TEMP_SCRIPTS_DIR . '/' . time() . '_noop.sh';
713
            file_put_contents(
714
                $filename,
715
                "$sleep $sleepTime; $command; $rm -rf $filename"
716
            );
717
            $noopCommand = "$nohup $sh $filename > $outFile 2>&1 &";
718
        } else {
719
            $noopCommand = "$nohup $command > $outFile 2>&1 &";
720
        }
721
        exec($noopCommand);
722
    }
723
724
    /**
725
     * Manages a daemon/worker process.
726
     *
727
     * @param string $cmd Command to execute
728
     * @param string $param Command parameters
729
     * @param string $procName Process name
730
     * @param string $action Action to perform
731
     * @param string $outFile Output file path
732
     *
733
     * @return array|bool Process status or operation result
734
     * @throws InvalidArgumentException If action is invalid
735
     */
736
    public static function processWorker(
737
        string $cmd,
738
        string $param,
739
        string $procName,
740
        string $action,
741
        string $outFile = self::DEFAULT_OUTPUT_FILE
742
    ): bool|array {
743
        if (!self::isValidAction($action)) {
744
            throw new InvalidArgumentException("Invalid action: $action");
745
        }
746
747
        $kill = Util::which('kill');
748
        $nohup = Util::which('nohup');
749
        $workerPID = self::getPidOfProcess($procName);
750
751
        return match ($action) {
752
            'status' => [
753
                'status' => ($workerPID !== '') ? 'Started' : 'Stopped',
754
                'app' => $procName,
755
                'PID' => $workerPID
756
            ],
757
            'restart' => self::handleWorkerRestart($kill, $workerPID, $nohup, $cmd, $param, $outFile),
758
            'stop' => self::handleWorkerStop($kill, $workerPID),
759
            'start' => self::handleWorkerStart($workerPID, $nohup, $cmd, $param, $outFile),
760
            default => throw new InvalidArgumentException("Unsupported action: $action"),
761
        };
762
    }
763
764
    /**
765
     * Handles worker restart operation.
766
     *
767
     * @param string $kill Kill command path
768
     * @param string $workerPID Worker process ID
769
     * @param string $nohup Nohup command path
770
     * @param string $cmd Command to execute
771
     * @param string $param Command parameters
772
     * @param string $outFile Output file path
773
     * @return bool
774
     */
775
    private static function handleWorkerRestart(
776
        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

776
        /** @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...
777
        string $workerPID,
778
        string $nohup,
779
        string $cmd,
780
        string $param,
781
        string $outFile
782
    ): bool {
783
        if ($workerPID !== '') {
784
            self::safeTerminateProcess($workerPID);
785
        }
786
        self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
787
        return true;
788
    }
789
790
    /**
791
     * Handles worker stop operation.
792
     *
793
     * @param string $kill Kill command path
794
     * @param string $workerPID Worker process ID
795
     * @return bool
796
     */
797
    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

797
    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...
798
    {
799
        if ($workerPID !== '') {
800
            self::safeTerminateProcess($workerPID);
801
        }
802
        return true;
803
    }
804
805
    /**
806
     * Handles worker start operation.
807
     *
808
     * @param string $workerPID Worker process ID
809
     * @param string $nohup Nohup command path
810
     * @param string $cmd Command to execute
811
     * @param string $param Command parameters
812
     * @param string $outFile Output file path
813
     * @return bool
814
     */
815
    private static function handleWorkerStart(
816
        string $workerPID,
817
        string $nohup,
818
        string $cmd,
819
        string $param,
820
        string $outFile
821
    ): bool {
822
        if ($workerPID === '') {
823
            self::mwExec("$nohup $cmd $param > $outFile 2>&1 &");
824
        }
825
        return true;
826
    }
827
828
    /**
829
     * Starts a daemon process with failure control.
830
     *
831
     * @param string $procName Process name
832
     * @param string $args Process arguments
833
     * @param int $attemptsCount Number of attempts to start
834
     * @param int $timeout Timeout between attempts in microseconds
835
     * @param string $outFile Output file path
836
     * @return bool Success status
837
     */
838
    public static function safeStartDaemon(
839
        string $procName,
840
        string $args,
841
        int $attemptsCount = self::DEFAULT_START_ATTEMPTS,
842
        int $timeout = self::DEFAULT_ATTEMPT_TIMEOUT,
843
        string $outFile = self::DEFAULT_OUTPUT_FILE
844
    ): bool {
845
        $result = true;
846
        $baseName = "safe-$procName";
847
        $safeLink = "/sbin/$baseName";
848
849
        try {
850
            Util::createUpdateSymlink('/etc/rc/worker_reload', $safeLink);
851
            self::killByName($baseName);
852
            self::killByName($procName);
853
854
            // Start the process in the background
855
            self::mwExecBg("$safeLink $args", $outFile);
856
857
            // Wait for the process to start with timeout
858
            $pid = self::waitForProcessStart($procName, $baseName, $attemptsCount, $timeout);
859
860
            if (empty($pid)) {
861
                SystemMessages::echoWithSyslog(" - Wait for start '$procName' fail" . PHP_EOL);
862
                $result = false;
863
            }
864
        } catch (Throwable $e) {
865
            SystemMessages::echoWithSyslog("Error starting daemon '$procName': " . $e->getMessage());
866
            $result = false;
867
        }
868
869
        return $result;
870
    }
871
872
}
873