Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.22% covered (success)
93.22%
110 / 118
75.00% covered (warning)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessQueue
93.22% covered (success)
93.22%
110 / 118
75.00% covered (warning)
75.00%
12 / 16
42.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 wait
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 startDetachedProcess
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 runBlockingProcess
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 runLabeledBlockingProcess
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createOutputCallback
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 createBufferedOutputCallback
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 flushDetachedProcessesOutput
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 writeDetachedOutput
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 runInOutputSection
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 shouldRenderLocalSection
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 resolveLabel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 formatProcessCommandLine
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getProcessCommandLine
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Process;
21
22use Closure;
23use FastForward\DevTools\Console\Output\GithubActionOutput;
24use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface;
25use FastForward\DevTools\Environment\EnvironmentInterface;
26use ReflectionProperty;
27use Symfony\Component\Console\Input\ArrayInput;
28use Symfony\Component\Console\Output\ConsoleOutputInterface;
29use Symfony\Component\Console\Output\NullOutput;
30use Symfony\Component\Console\Output\OutputInterface;
31use Symfony\Component\Console\Style\SymfonyStyle;
32use Symfony\Component\Process\Exception\ProcessStartFailedException;
33use Symfony\Component\Process\Process;
34
35/**
36 * Executes queued processes sequentially while supporting detached entries and
37 * optional failure suppression.
38 *
39 * Regular processes are executed in the order they were added and block the
40 * queue until completion. Detached processes are started in the order they were
41 * added but do not block subsequent entries.
42 *
43 * A detached process that starts successfully is considered dispatched. Because
44 * this implementation does not wait for detached processes to finish during `run()`,
45 * their eventual runtime exit status cannot be incorporated into the final queue
46 * result. However, a detached process that cannot be started at all is treated
47 * as a startup failure and MAY affect the final status code unless its failure
48 * is explicitly configured to be ignored.
49 *
50 * Buffered detached output is flushed only after the blocking portion of the
51 * queue has finished, which keeps later process output from interleaving with
52 * currently running blocking commands.
53 */
54 class ProcessQueue implements ProcessQueueInterface
55{
56    private static ?ReflectionProperty $commandLineProperty = null;
57
58    /**
59     * Stores queued process entries in insertion order.
60     *
61     * @var list<array{process: Process, ignoreFailure: bool, detached: bool, label: string}>
62     */
63    private array $entries = [];
64
65    /**
66     * Stores detached processes that have already been started and whose output
67     * SHALL be emitted only after blocking processes finish.
68     *
69     * @var list<object{process: Process, label: string, standardOutput: string, errorOutput: string}>
70     */
71    private array $runningDetachedProcesses = [];
72
73    /**
74     * @param GithubActionOutput $githubActionOutput wraps grouped queue output in GitHub Actions logs when supported
75     * @param ProcessEnvironmentConfiguratorInterface $environmentConfigurator
76     * @param EnvironmentInterface $environment reads runtime environment flags
77     * @param OutputCapabilityDetectorInterface $outputCapabilityDetector detects ANSI-capable output
78     */
79    public function __construct(
80        private  GithubActionOutput $githubActionOutput,
81        private  ProcessEnvironmentConfiguratorInterface $environmentConfigurator,
82        private  EnvironmentInterface $environment,
83        private  OutputCapabilityDetectorInterface $outputCapabilityDetector,
84    ) {}
85
86    /**
87     * Adds a process to the queue.
88     *
89     * @param Process $process the process instance that SHALL be added to the queue
90     * @param bool $ignoreFailure indicates whether a failure of this process MUST NOT affect the final queue result
91     * @param bool $detached indicates whether this process SHALL be started without blocking the next queued process
92     * @param ?string $label an optional label that MAY be used to present the process output as a grouped block
93     */
94    public function add(
95        Process $process,
96        bool $ignoreFailure = false,
97        bool $detached = false,
98        ?string $label = null
99    ): void {
100        $this->entries[] = [
101            'process' => $process,
102            'ignoreFailure' => $ignoreFailure,
103            'detached' => $detached,
104            'label' => $this->resolveLabel($process, $label),
105        ];
106    }
107
108    /**
109     * Runs the queued processes and returns the resulting status code.
110     *
111     * The returned status code represents the first non-zero exit code observed
112     * among non-ignored blocking processes, or among non-ignored detached
113     * processes that fail to start. Detached processes that start successfully
114     * are not awaited iteratively inside run() and therefore do not contribute
115     * their eventual runtime exit code to the returned result.
116     *
117     * @param OutputInterface $output the output used during execution
118     *
119     * @return int the final exit status code produced by the queue execution
120     */
121    public function run(?OutputInterface $output = new NullOutput()): int
122    {
123        $statusCode = self::SUCCESS;
124
125        foreach ($this->entries as $entry) {
126            /** @var Process $process */
127            $process = $entry['process'];
128            $ignoreFailure = $entry['ignoreFailure'];
129            $detached = $entry['detached'];
130            /** @var string $label */
131            $label = $entry['label'];
132
133            if ($detached) {
134                $startupStatusCode = $this->startDetachedProcess($process, $output, $label);
135
136                if (
137                    ! $ignoreFailure
138                    && self::SUCCESS !== $startupStatusCode
139                    && self::SUCCESS === $statusCode
140                ) {
141                    $statusCode = $startupStatusCode;
142                }
143
144                continue;
145            }
146
147            $processStatusCode = $this->runLabeledBlockingProcess($process, $output, $label);
148
149            if (
150                ! $ignoreFailure
151                && self::SUCCESS !== $processStatusCode
152                && self::SUCCESS === $statusCode
153            ) {
154                $statusCode = $processStatusCode;
155            }
156        }
157
158        $this->wait($output);
159        $this->entries = [];
160
161        return $statusCode;
162    }
163
164    /**
165     * Waits for detached processes to finish and flushes completed buffered output.
166     *
167     * @param ?OutputInterface $output the output interface to which process output and diagnostics MAY be written
168     */
169    public function wait(?OutputInterface $output = null): void
170    {
171        $output ??= new NullOutput();
172
173        while ([] !== $this->runningDetachedProcesses) {
174            if ($this->flushDetachedProcessesOutput($output)) {
175                continue;
176            }
177
178            usleep(10000);
179        }
180    }
181
182    /**
183     * Starts a process in detached mode without waiting for completion.
184     *
185     * A detached process is considered successfully dispatched when its startup
186     * sequence completes without throwing an exception.
187     *
188     * @param Process $process the process to start
189     * @param OutputInterface $output the parent output used to infer process environment
190     * @param string $label the label used when presenting the buffered output
191     *
192     * @return int returns 0 when the process starts successfully, or a non-zero
193     *             value when startup fails
194     */
195    private function startDetachedProcess(Process $process, OutputInterface $output, string $label): int
196    {
197        $entry = (object) [
198            'process' => $process,
199            'label' => $label,
200            'standardOutput' => '',
201            'errorOutput' => '',
202        ];
203
204        try {
205            $this->environmentConfigurator->configure($process, $output);
206            $process->start($this->createBufferedOutputCallback($entry));
207            $this->runningDetachedProcesses[] = $entry;
208
209            return self::SUCCESS;
210        } catch (ProcessStartFailedException) {
211            return self::FAILURE;
212        }
213    }
214
215    /**
216     * Runs a process synchronously and returns its exit code.
217     *
218     * @param Process $process the process to execute
219     * @param OutputInterface $output the output that SHALL receive process output
220     *
221     * @return int The exit code returned by the process. A startup failure is
222     *             normalized to a non-zero exit code.
223     */
224    private function runBlockingProcess(Process $process, OutputInterface $output): int
225    {
226        try {
227            $this->environmentConfigurator->configure($process, $output);
228            $process->run($this->createOutputCallback($output));
229
230            return $process->getExitCode() ?? self::FAILURE;
231        } catch (ProcessStartFailedException) {
232            return self::FAILURE;
233        }
234    }
235
236    /**
237     * Runs a blocking process inside a grouped GitHub Actions log section.
238     *
239     * @param Process $process the process to execute
240     * @param OutputInterface $output the output that SHALL receive process output
241     * @param string $label the label that SHALL be used to group command output
242     *
243     * @return int the resulting process exit code
244     */
245    private function runLabeledBlockingProcess(Process $process, OutputInterface $output, string $label): int
246    {
247        $runBlockingProcess = fn(): int => $this->runInOutputSection(
248            $label,
249            $output,
250            fn(): int => $this->runBlockingProcess($process, $output)
251        );
252
253        return $this->githubActionOutput->group($label, $runBlockingProcess);
254    }
255
256    /**
257     * Creates a callback that forwards process output to the configured output.
258     *
259     * The callback SHALL stream both standard output and error output exactly
260     * as received, preserving ANSI escape sequences when the underlying command
261     * emits them.
262     *
263     * @param OutputInterface $output the output destination
264     *
265     * @return Closure(string, string):void
266     */
267    private function createOutputCallback(OutputInterface $output): Closure
268    {
269        return static function (string $type, string $buffer) use ($output): void {
270            if (
271                Process::ERR === $type
272                && $output instanceof ConsoleOutputInterface
273            ) {
274                $output->getErrorOutput()
275                    ->write($buffer);
276
277                return;
278            }
279
280            $output->write($buffer);
281        };
282    }
283
284    /**
285     * Creates a callback that buffers detached process output until flushing.
286     *
287     * @param object{process: Process, label: string, standardOutput: string, errorOutput: string} $entry
288     *
289     * @return Closure(string, string):void
290     */
291    private function createBufferedOutputCallback(object $entry): Closure
292    {
293        return static function (string $type, string $buffer) use ($entry): void {
294            if (Process::ERR === $type) {
295                $entry->errorOutput .= $buffer;
296
297                return;
298            }
299
300            $entry->standardOutput .= $buffer;
301        };
302    }
303
304    /**
305     * Flushes completed detached process output in enqueue order.
306     *
307     * @param OutputInterface $output the output that SHALL receive detached process output
308     *
309     * @return bool whether at least one detached process output has been flushed
310     */
311    private function flushDetachedProcessesOutput(OutputInterface $output): bool
312    {
313        $remainingDetachedProcesses = [];
314        $hasFlushedDetachedProcess = false;
315
316        foreach ($this->runningDetachedProcesses as $entry) {
317            if ($entry->process->isRunning()) {
318                $remainingDetachedProcesses[] = $entry;
319
320                continue;
321            }
322
323            $hasFlushedDetachedProcess = true;
324            $entry->process->wait();
325            $writeDetachedOutput = fn(): bool => $this->writeDetachedOutput(
326                $entry->standardOutput,
327                $entry->errorOutput,
328                $output
329            );
330            $renderDetachedOutput = fn(): mixed => $this->runInOutputSection(
331                $entry->label,
332                $output,
333                $writeDetachedOutput
334            );
335
336            $this->githubActionOutput->group($entry->label, $renderDetachedOutput);
337        }
338
339        $this->runningDetachedProcesses = $remainingDetachedProcesses;
340
341        return $hasFlushedDetachedProcess;
342    }
343
344    /**
345     * Writes buffered detached output to the configured output.
346     *
347     * @param string $standardOutput
348     * @param string $errorOutput
349     * @param OutputInterface $output the output that SHALL receive detached process output
350     *
351     * @return bool always returns true to support grouped callback usage
352     */
353    private function writeDetachedOutput(string $standardOutput, string $errorOutput, OutputInterface $output): bool
354    {
355        if ('' !== $standardOutput) {
356            $output->write($standardOutput);
357        }
358
359        if (
360            '' !== $errorOutput
361            && $output instanceof ConsoleOutputInterface
362        ) {
363            $output->getErrorOutput()
364                ->write($errorOutput);
365        } elseif ('' !== $errorOutput) {
366            $output->write($errorOutput);
367        }
368
369        return true;
370    }
371
372    /**
373     * Runs a callback inside a local Symfony-style section when output supports it.
374     *
375     * GitHub Actions already receives native log groups, while non-ANSI outputs
376     * and JSON buffers should remain free of extra presentation chrome.
377     *
378     * @template TResult
379     *
380     * @param string $label the human-readable process label
381     * @param OutputInterface $output the output that MAY receive section chrome
382     * @param Closure(): TResult $callback the callback to run
383     *
384     * @return TResult
385     */
386    private function runInOutputSection(string $label, OutputInterface $output, Closure $callback): mixed
387    {
388        if (! $this->shouldRenderLocalSection($output)) {
389            return $callback();
390        }
391
392        (new SymfonyStyle(new ArrayInput([]), $output))->section($label);
393
394        return $callback();
395    }
396
397    /**
398     * Determines whether local Symfony section chrome should be emitted.
399     *
400     * @param OutputInterface $output the parent process output
401     *
402     * @return bool true when a local section should be rendered
403     */
404    private function shouldRenderLocalSection(OutputInterface $output): bool
405    {
406        return $this->outputCapabilityDetector->supportsAnsi($output)
407            && null === $this->environment->get('GITHUB_ACTIONS');
408    }
409
410    /**
411     * Resolves the label used when presenting queued process output.
412     *
413     * @param Process $process the queued process instance
414     * @param ?string $label the optional label provided by the caller
415     *
416     * @return string the resolved presentation label
417     */
418    private function resolveLabel(Process $process, ?string $label = null): string
419    {
420        if (null !== $label) {
421            return $label;
422        }
423
424        return 'Running ' . $this->formatProcessCommandLine($process);
425    }
426
427    /**
428     * Formats the configured process command line without shell escaping noise.
429     *
430     * @param Process $process the queued process instance
431     *
432     * @return string the human-readable command line
433     */
434    private function formatProcessCommandLine(Process $process): string
435    {
436        $commandLine = $this->getProcessCommandLine($process);
437
438        if (\is_array($commandLine)) {
439            return implode(' ', array_map(strval(...), $commandLine));
440        }
441
442        return $commandLine;
443    }
444
445    /**
446     * Reads the raw configured Process command line.
447     *
448     * Symfony keeps the configured command line in a private property, so the
449     * queue reads it reflectively to build a cleaner default label than the
450     * shell-escaped output returned by `Process::getCommandLine()`.
451     *
452     * @param Process $process the queued process instance
453     *
454     * @return array<int, string>|string
455     */
456    private function getProcessCommandLine(Process $process): array|string
457    {
458        self::$commandLineProperty ??= new ReflectionProperty(Process::class, 'commandline');
459
460        if (! self::$commandLineProperty->isInitialized($process)) {
461            return 'process';
462        }
463
464        /** @var array<int, string>|string $commandLine */
465        $commandLine = self::$commandLineProperty->getValue($process);
466
467        return $commandLine;
468    }
469}