Test Failed
Pull Request — master (#211)
by Dmitriy
03:59 queued 01:29
created

Serve::isWindows()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 0
cts 0
cp 0
crap 2
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\Console\Command;
6
7
use Symfony\Component\Console\Attribute\AsCommand;
8
use Symfony\Component\Console\Command\Command;
9
use Symfony\Component\Console\Completion\CompletionInput;
10
use Symfony\Component\Console\Completion\CompletionSuggestions;
11
use Symfony\Component\Console\Input\InputArgument;
12
use Symfony\Component\Console\Input\InputInterface;
13
use Symfony\Component\Console\Input\InputOption;
14
use Symfony\Component\Console\Output\OutputInterface;
15
use Symfony\Component\Console\Style\SymfonyStyle;
16
use Yiisoft\Yii\Console\ExitCode;
17
18
use function explode;
19
use function fclose;
20
use function file_exists;
21
use function fsockopen;
22
use function is_dir;
23
use function passthru;
24
25
#[AsCommand('serve', 'Runs PHP built-in web server')]
26
final class Serve extends Command
27
{
28
    public const EXIT_CODE_NO_DOCUMENT_ROOT = 2;
29
    public const EXIT_CODE_NO_ROUTING_FILE = 3;
30
    public const EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS = 5;
31
32
    private string $defaultAddress;
33
    private string $defaultPort;
34
    private string $defaultDocroot;
35
    private string $defaultRouter;
36
    private int $defaultWorkers;
37
38
    /**
39
     * @psalm-param array{
40
     *     address?:non-empty-string,
41
     *     port?:non-empty-string,
42
     *     docroot?:string,
43
     *     router?:string,
44
     *     workers?:int|string
45
     * } $options
46
     */
47 49
    public function __construct(private ?string $appRootPath = null, ?array $options = [])
48
    {
49 49
        $this->defaultAddress = $options['address'] ?? '127.0.0.1';
50 49
        $this->defaultPort = $options['port'] ?? '8080';
51 49
        $this->defaultDocroot = $options['docroot'] ?? 'public';
52 49
        $this->defaultRouter = $options['router'] ?? 'public/index.php';
53 49
        $this->defaultWorkers = (int) ($options['workers'] ?? 2);
54
55 49
        parent::__construct();
56
    }
57
58 49
    public function configure(): void
59
    {
60 49
        $this
61 49
            ->setHelp(
62 49
                'In order to access server from remote machines use 0.0.0.0:8000. That is especially useful when running server in a virtual machine.'
63 49
            )
64 49
            ->addArgument('address', InputArgument::OPTIONAL, 'Host to serve at', $this->defaultAddress)
65 49
            ->addOption('port', 'p', InputOption::VALUE_OPTIONAL, 'Port to serve at', $this->defaultPort)
66 49
            ->addOption(
67 49
                'docroot',
68 49
                't',
69 49
                InputOption::VALUE_OPTIONAL,
70 49
                'Document root to serve from',
71 49
                $this->defaultDocroot
72 49
            )
73 49
            ->addOption('router', 'r', InputOption::VALUE_OPTIONAL, 'Path to router script', $this->defaultRouter)
74 49
            ->addOption(
75 49
                'workers',
76 49
                'w',
77 49
                InputOption::VALUE_OPTIONAL,
78 49
                'Workers number the server will start with',
79 49
                $this->defaultWorkers
80 49
            )
81 49
            ->addOption('env', 'e', InputOption::VALUE_OPTIONAL, 'It is only used for testing.')
82 49
            ->addOption('open', 'o', InputOption::VALUE_OPTIONAL, 'Opens the serving server in the default browser.')
83
            ->addOption('xdebug', 'x', InputOption::VALUE_OPTIONAL, 'Enables XDEBUG session.', false);
84
    }
85 1
86
    public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
87 1
    {
88 1
        if ($input->mustSuggestArgumentValuesFor('address')) {
89 1
            $suggestions->suggestValues(['localhost', '127.0.0.1', '0.0.0.0']);
90
            return;
91
        }
92
    }
93 6
94
    protected function execute(InputInterface $input, OutputInterface $output): int
95 6
    {
96 6
        $io = new SymfonyStyle($input, $output);
97 6
        $io->title('Yii3 Development Server');
98
        $io->writeln('https://yiiframework.com' . "\n");
99
100 6
        /** @var string $address */
101
        $address = $input->getArgument('address');
102
103 6
        /** @var string $router */
104 6
        $router = $input->getOption('router');
105
        $workers = (int) $input->getOption('workers');
106
107 6
        /** @var string $port */
108
        $port = $input->getOption('port');
109
110 6
        /** @var string $docroot */
111
        $docroot = $input->getOption('docroot');
112 6
113 4
        if ($router === $this->defaultRouter && !file_exists($this->defaultRouter)) {
114 4
            $io->warning(
115 4
                'Default router "' . $this->defaultRouter . '" does not exist. Serving without router. URLs with dots may fail.'
116 4
            );
117
            $router = null;
118
        }
119
120 6
        /** @var string $env */
121
        $env = $input->getOption('env');
122 6
123
        $documentRoot = $this->getRootPath() . DIRECTORY_SEPARATOR . $docroot;
124 6
125 5
        if (!str_contains($address, ':')) {
126
            $address .= ':' . $port;
127
        }
128 6
129 1
        if (!is_dir($documentRoot)) {
130 1
            $io->error("Document root \"$documentRoot\" does not exist.");
131
            return self::EXIT_CODE_NO_DOCUMENT_ROOT;
132
        }
133 5
134 1
        if ($this->isAddressTaken($address)) {
135 1
            if ($this->isWindows()) {
136
                $io->error("Port {$port} is taken by another process");
137
                return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
138 4
            }
139 1
140 1
            $runningCommandPIDs = trim((string) shell_exec('lsof -ti :8080 -s TCP:LISTEN'));
141
            if (empty($runningCommandPIDs)) {
142
                $io->error("Port {$port} is taken by another process");
143 3
                return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
144
            }
145 3
146
            $runningCommandPIDs = array_filter(explode("\n", $runningCommandPIDs));
147 3
            sort($runningCommandPIDs);
148 3
149
            $io->block(
150
                [
151 3
                    "Port {$port} is taken by the processes:",
152 3
                    ...array_map(
153
                        fn ($pid) => sprintf(
154 3
                            '#%s: %s',
155
                            $pid,
156
                            shell_exec("ps -o command= -p {$pid}"),
157 3
                        ),
158 3
                        $runningCommandPIDs,
159 3
                    ),
160 3
                ],
161 3
                'ERROR',
162 3
                'error',
163 3
            );
164 3
            if (!$io->confirm('Kill the process', true)) {
165 3
                return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
166 3
            }
167 3
            $io->info([
168 3
                'Stopping the processes...',
169 3
            ]);
170 3
            $out = array_filter(
171
                array_map(
172 3
                    fn ($pid) => shell_exec("kill -9 {$pid}"),
173
                    $runningCommandPIDs,
174 3
                )
175 3
            );
176
            if (!empty($out)) {
177 3
                $io->error($out);
178 3
                return self::EXIT_CODE_ADDRESS_TAKEN_BY_ANOTHER_PROCESS;
179 3
            }
180 3
        }
181
182 3
        if ($router !== null && !file_exists($router)) {
183
            $io->error("Routing file \"$router\" does not exist.");
184 3
            return self::EXIT_CODE_NO_ROUTING_FILE;
185 3
        }
186
187
        $command = [];
188
189
        $isLinux = DIRECTORY_SEPARATOR !== '\\';
190
191
        if ($isLinux) {
192
            $command[] = 'PHP_CLI_SERVER_WORKERS=' . $workers;
193
        }
194
195
        $xDebugInstalled = extension_loaded('xdebug');
196
        $xDebugEnabled = $isLinux && $xDebugInstalled && $input->hasOption('xdebug') && $input->getOption(
197
            'xdebug'
198 5
        ) === null;
199
200 5
        if ($xDebugEnabled) {
201 5
            $command[] = 'XDEBUG_MODE=debug XDEBUG_TRIGGER=yes';
202
        }
203 5
        $outputTable = [];
204 4
        $outputTable[] = ['PHP', PHP_VERSION];
205
        $outputTable[] = [
206
            'xDebug',
207 1
            $xDebugInstalled ? sprintf(
208 1
                '%s, %s',
209
                phpversion('xdebug'),
210
                $xDebugEnabled ? '<info> Enabled </>' : '<error> Disabled </>',
211 6
            ) : '<error>Not installed</>',
212
            '--xdebug',
213 6
        ];
214
        $outputTable[] = ['Workers', $isLinux ? $workers : 'Not supported', '--workers, -w'];
215
        $outputTable[] = ['Address', $address];
216
        $outputTable[] = ['Document root', $documentRoot, '--docroot, -t'];
217 6
        $outputTable[] = ($router ? ['Routing file', $router, '--router, -r'] : []);
218
219
        $io->table(['Configuration', null, 'Options'], $outputTable);
220
221
        $command[] = '"' . PHP_BINARY . '"' . " -S $address -t \"$documentRoot\" $router";
222
        $command = implode(' ', $command);
223
224
        $output->writeln([
225
            'Executing: ',
226
            sprintf('<info>%s</>', $command),
227
        ], OutputInterface::VERBOSITY_VERBOSE);
228
229
        $io->success('Quit the server with CTRL-C or COMMAND-C.');
230
231
        if ($env === 'test') {
232
            return ExitCode::OK;
233
        }
234
235
        $openInBrowser = $input->hasOption('open') && $input->getOption('open') === null;
0 ignored issues
show
Unused Code introduced by
The assignment to $openInBrowser is dead and can be removed.
Loading history...
236
237
        //if ($openInBrowser) {
238
        //    passthru('open http://' . $address);
239
        //}
240
        passthru($command, $result);
241
        //$descriptorspec = array(
242
        //    0 => array("pipe", "r"),  // stdin - канал, из которого дочерний процесс будет читать
243
        //    1 => array("pipe", "w"),  // stdout - канал, в который дочерний процесс будет записывать
244
        //    2 => array("file", "/tmp/error-output.txt", "a") // stderr - файл для записи
245
        //);
246
247
        //$cwd = dirname($documentRoot);
248
        //$process = proc_open($command, $descriptorspec, $cwd);
249
        //
250
        //var_dump($process);
251
252
        return 0;
253
    }
254
255
    /**
256
     * @param string $address The server address.
257
     *
258
     * @return bool If address is already in use.
259
     */
260
    private function isAddressTaken(string $address): bool
261
    {
262
        [$hostname, $port] = explode(':', $address);
263
        $fp = @fsockopen($hostname, (int) $port, $errno, $errstr, 3);
264
265
        if ($fp === false) {
266
            return false;
267
        }
268
269
        fclose($fp);
270
        return true;
271
    }
272
273
    private function getRootPath(): string
274
    {
275
        if ($this->appRootPath !== null) {
276
            return rtrim($this->appRootPath, DIRECTORY_SEPARATOR);
277
        }
278
279
        return getcwd();
280
    }
281
282
    private function isWindows(): bool
283
    {
284
        return stripos(PHP_OS, 'Win') === 0;
285
    }
286
}
287