1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 */
8namespace Piwik\CliMulti;
9
10use Piwik\CliMulti;
11use Piwik\Common;
12use Piwik\Container\StaticContainer;
13use Piwik\Filesystem;
14use Piwik\SettingsServer;
15
16/**
17 * There are three different states
18 * - PID file exists with empty content: Process is created but not started
19 * - PID file exists with the actual process PID as content: Process is running
20 * - PID file does not exist: Process is marked as finished
21 *
22 * Class Process
23 */
24class Process
25{
26    const PS_COMMAND = 'ps x';
27    const AWK_COMMAND = 'awk \'! /defunct/ {print $1}\'';
28
29    private $finished = null;
30    private $pidFile = '';
31    private $timeCreation = null;
32    private static $isSupported = null;
33    private $pid = null;
34    private $started = null;
35
36    public function __construct($pid)
37    {
38        if (!Filesystem::isValidFilename($pid)) {
39            throw new \Exception('The given pid has an invalid format');
40        }
41
42        $pidDir = CliMulti::getTmpPath();
43        Filesystem::mkdir($pidDir);
44
45        $this->pidFile      = $pidDir . '/' . $pid . '.pid';
46        $this->timeCreation = time();
47        $this->pid = $pid;
48
49        $this->markAsNotStarted();
50    }
51
52    private static function isForcingAsyncProcessMode()
53    {
54        try {
55            return (bool) StaticContainer::get('test.vars.forceCliMultiViaCurl');
56        } catch (\Exception $ex) {
57            return false;
58        }
59    }
60
61    public function getPid()
62    {
63        return $this->pid;
64    }
65
66    private function markAsNotStarted()
67    {
68        $content = $this->getPidFileContent();
69
70        if ($this->doesPidFileExist($content)) {
71            return;
72        }
73
74        $this->writePidFileContent('');
75    }
76
77    public function hasStarted($content = null)
78    {
79        if (!$this->started) {
80            $this->started = $this->checkPidIfHasStarted($content);
81        }
82        // PID will be deleted when process has finished so we want to remember this process started at some point. Otherwise we might return false here once the process finished.
83        // therefore we want to "cache" a successful start
84        return $this->started;
85    }
86
87    private function checkPidIfHasStarted($content = null)
88    {
89        if (is_null($content)) {
90            $content = $this->getPidFileContent();
91        }
92
93        if (!$this->doesPidFileExist($content)) {
94            // process is finished, this means there was a start before
95            return true;
96        }
97
98        if ('' === trim($content)) {
99            // pid file is overwritten by startProcess()
100            return false;
101        }
102
103        // process is probably running or pid file was not removed
104        return true;
105    }
106
107    public function hasFinished()
108    {
109        if ($this->finished) {
110            return true;
111        }
112
113        $content = $this->getPidFileContent();
114
115        return !$this->doesPidFileExist($content);
116    }
117
118    public function getSecondsSinceCreation()
119    {
120        return time() - $this->timeCreation;
121    }
122
123    public function startProcess()
124    {
125        $this->writePidFileContent(getmypid());
126    }
127
128    public function isRunning()
129    {
130        $content = $this->getPidFileContent();
131
132        if (!$this->doesPidFileExist($content)) {
133            return false;
134        }
135
136        if (!$this->pidFileSizeIsNormal($content)) {
137            $this->finishProcess();
138            return false;
139        }
140
141        if ($this->isProcessStillRunning($content)) {
142            return true;
143        }
144
145        if ($this->hasStarted($content)) {
146            $this->finishProcess();
147        }
148
149        return false;
150    }
151
152    private function pidFileSizeIsNormal($content)
153    {
154        $size = Common::mb_strlen($content);
155
156        return $size < 500;
157    }
158
159    public function finishProcess()
160    {
161        $this->finished = true;
162        Filesystem::deleteFileIfExists($this->pidFile);
163    }
164
165    private function doesPidFileExist($content)
166    {
167        return false !== $content;
168    }
169
170    private function isProcessStillRunning($content)
171    {
172        if (!self::isSupported()) {
173            return true;
174        }
175
176        $lockedPID   = trim($content);
177        $runningPIDs = self::getRunningProcesses();
178
179        return !empty($lockedPID) && in_array($lockedPID, $runningPIDs);
180    }
181
182    private function getPidFileContent()
183    {
184        return @file_get_contents($this->pidFile);
185    }
186
187    /**
188     * Tests only
189     * @internal
190     * @param $content
191     */
192    public function writePidFileContent($content)
193    {
194        file_put_contents($this->pidFile, $content);
195    }
196
197    public static function isSupported()
198    {
199        if (!isset(self::$isSupported)) {
200            $reasons = self::isSupportedWithReason();
201            self::$isSupported = empty($reasons);
202        }
203        return self::$isSupported;
204    }
205
206    public static function isSupportedWithReason()
207    {
208        $reasons = [];
209
210        if (defined('PIWIK_TEST_MODE')
211            && self::isForcingAsyncProcessMode()
212        ) {
213            $reasons[] = 'forcing multicurl use for tests';
214        }
215
216        if (SettingsServer::isWindows()) {
217            $reasons[] = 'not supported on windows';
218        }
219
220        if (self::isMethodDisabled('shell_exec')) {
221            $reasons[] = 'shell_exec is disabled';
222            return $reasons; // shell_exec is used for almost every other check
223        }
224
225        $getMyPidDisabled = self::isMethodDisabled('getmypid');
226        if ($getMyPidDisabled) {
227            $reasons[] = 'getmypid is disabled';
228        }
229
230        if (self::isSystemNotSupported()) {
231            $reasons[] = 'system returned by `uname -a` is not supported';
232        }
233
234        if (!self::psExistsAndRunsCorrectly()) {
235            $reasons[] = 'shell_exec(' . self::PS_COMMAND . '" 2> /dev/null") did not return a success code';
236        } else if (!$getMyPidDisabled) {
237            $pid = @getmypid();
238            if (empty($pid) || !in_array($pid, self::getRunningProcesses())) {
239                $reasons[] = 'could not find our pid (from getmypid()) in the output of `' . self::PS_COMMAND . '`';
240            }
241        }
242
243        if (!self::awkExistsAndRunsCorrectly()) {
244            $reasons[] = 'awk is not available or did not run as we would expect it to';
245        }
246
247        return $reasons;
248    }
249
250    private static function psExistsAndRunsCorrectly()
251    {
252        return self::returnsSuccessCode(self::PS_COMMAND . ' 2>/dev/null');
253    }
254
255    private static function awkExistsAndRunsCorrectly()
256    {
257        $testResult = @shell_exec('echo " 537 s000 Ss 0:00.05 login -pfl theuser /bin/bash -c exec -la bash /bin/bash" | ' . self::AWK_COMMAND . ' 2>/dev/null');
258        return trim($testResult) == '537';
259    }
260
261    private static function isSystemNotSupported()
262    {
263        $uname = @shell_exec('uname -a 2> /dev/null');
264
265        if (empty($uname)) {
266            $uname = php_uname();
267        }
268
269        if (strpos($uname, 'synology') !== false) {
270            return true;
271        }
272        return false;
273    }
274
275    public static function isMethodDisabled($command)
276    {
277        if (!function_exists($command)) {
278            return true;
279        }
280
281        $disabled = explode(',', ini_get('disable_functions'));
282        $disabled = array_map('trim', $disabled);
283        return in_array($command, $disabled)  || !function_exists($command);
284    }
285
286    private static function returnsSuccessCode($command)
287    {
288        $exec = $command . ' > /dev/null 2>&1; echo $?';
289        $returnCode = @shell_exec($exec);
290        $returnCode = trim($returnCode);
291        return 0 == (int) $returnCode;
292    }
293
294    public static function getListOfRunningProcesses()
295    {
296        $processes = @shell_exec(self::PS_COMMAND . ' 2>/dev/null');
297        if (empty($processes)) {
298            return array();
299        }
300        return explode("\n", $processes);
301    }
302
303    /**
304     * @return int[] The ids of the currently running processes
305     */
306     public static function getRunningProcesses()
307     {
308         $ids = explode("\n", trim(shell_exec(self::PS_COMMAND . ' 2>/dev/null | ' . self::AWK_COMMAND . ' 2>/dev/null')));
309
310         $ids = array_map('intval', $ids);
311         $ids = array_filter($ids, function ($id) {
312            return $id > 0;
313        });
314
315         return $ids;
316     }
317}
318