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