Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
18 / 18
CRAP
100.00% covered (success)
100.00%
1 / 1
WorkerState
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
18 / 18
21
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activateParent
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 activateChild
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRunning
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getStatus
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExitCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTerminationSignal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReadableStreams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 drainOutput
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 writeOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeErrorOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 closeChildSide
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markTerminated
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 markDetached
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of fast-forward/fork.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/fork
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Fork\Worker;
20
21use FastForward\Fork\Signal\Signal;
22use Psr\Log\LoggerInterface;
23
24use function pcntl_wifexited;
25use function pcntl_wifsignaled;
26use function pcntl_wexitstatus;
27use function pcntl_wtermsig;
28
29/**
30 * Stores the mutable runtime state associated with a worker process.
31 *
32 * This component is responsible for tracking and synchronizing the lifecycle
33 * and execution metadata of a worker across parent and child processes.
34 *
35 * It encapsulates:
36 * - Process identification (PID)
37 * - Execution state (running, terminated, detached)
38 * - Exit metadata (status, exit code, termination signal)
39 * - Output streaming via an internal transport
40 *
41 * Instances of this class MUST be created through the factory method to ensure
42 * proper initialization of internal transport resources.
43 *
44 * @internal
45 */
46 class WorkerState
47{
48    /**
49     * Stores the worker PID once either side of the fork has been activated.
50     *
51     * A value of 0 indicates that the process has not yet been initialized.
52     */
53    private int $pid = 0;
54
55    /**
56     * Indicates whether the worker is still considered running.
57     *
58     * This flag SHALL transition to false once the worker terminates or becomes detached.
59     */
60    private bool $running = true;
61
62    /**
63     * Stores the raw status value returned by the operating system.
64     *
65     * This value MAY be null until the worker has terminated.
66     */
67    private ?int $status = null;
68
69    /**
70     * Stores the normalized exit code when the worker exits normally.
71     *
72     * This value SHALL be null if the worker has not exited or was terminated by a signal.
73     */
74    private ?int $exitCode = null;
75
76    /**
77     * Stores the signal that terminated the worker, when applicable.
78     *
79     * This value SHALL be null if the worker exited normally or has not yet terminated.
80     */
81    private ?Signal $terminationSignal = null;
82
83    /**
84     * Initializes the worker state with its output transport.
85     *
86     * @param WorkerOutputTransport $outputTransport transport used to stream output from the worker process
87     */
88    private function __construct(
89        private  WorkerOutputTransport $outputTransport,
90    ) {}
91
92    /**
93     * Creates a fresh runtime state instance with a new output transport.
94     *
95     * The method MUST ensure that all required resources are properly initialized.
96     *
97     * @return self a new worker state instance
98     */
99    public static function create(): self
100    {
101        return new self(WorkerOutputTransport::create());
102    }
103
104    /**
105     * Activates the parent-side view of the worker state.
106     *
107     * The parent process MUST set the PID and initialize its side of the transport.
108     *
109     * @param int $pid the PID of the forked worker process
110     */
111    public function activateParent(int $pid): void
112    {
113        $this->pid = $pid;
114        $this->outputTransport->activateParentSide();
115    }
116
117    /**
118     * Activates the child-side view of the worker state.
119     *
120     * The child process MUST set its PID and release parent-side transport resources.
121     *
122     * @param int $pid the PID of the current process
123     */
124    public function activateChild(int $pid): void
125    {
126        $this->pid = $pid;
127        $this->outputTransport->activateChildSide();
128    }
129
130    /**
131     * Returns the PID currently associated with the worker state.
132     *
133     * @return int the worker process identifier
134     */
135    public function getPid(): int
136    {
137        return $this->pid;
138    }
139
140    /**
141     * Indicates whether the worker is still running.
142     *
143     * @return bool true if the worker is active; otherwise false
144     */
145    public function isRunning(): bool
146    {
147        return $this->running;
148    }
149
150    /**
151     * Returns the raw wait status when available.
152     *
153     * @return int|null the raw status or null if not yet available
154     */
155    public function getStatus(): ?int
156    {
157        return $this->status;
158    }
159
160    /**
161     * Returns the normalized exit code when the worker exited normally.
162     *
163     * @return int|null the exit code or null if not applicable
164     */
165    public function getExitCode(): ?int
166    {
167        return $this->exitCode;
168    }
169
170    /**
171     * Returns the terminating signal when the worker exited due to a signal.
172     *
173     * @return Signal|null the terminating signal or null if not applicable
174     */
175    public function getTerminationSignal(): ?Signal
176    {
177        return $this->terminationSignal;
178    }
179
180    /**
181     * Returns the stdout accumulated for the worker so far.
182     *
183     * @return string captured standard output
184     */
185    public function getOutput(): string
186    {
187        return $this->outputTransport->getOutput();
188    }
189
190    /**
191     * Returns the error output accumulated for the worker so far.
192     *
193     * @return string captured error output
194     */
195    public function getErrorOutput(): string
196    {
197        return $this->outputTransport->getErrorOutput();
198    }
199
200    /**
201     * Returns the readable streams that still belong to a running worker.
202     *
203     * If the worker is no longer running, an empty array SHALL be returned.
204     *
205     * @return array<int, resource> readable streams
206     */
207    public function getReadableStreams(): array
208    {
209        if (! $this->running) {
210            return [];
211        }
212
213        return $this->outputTransport->getReadableStreams();
214    }
215
216    /**
217     * Drains any readable output from the worker transport.
218     *
219     * The method MAY operate on a subset of streams if provided and SHOULD be
220     * invoked periodically to avoid buffer saturation.
221     *
222     * @param array<int, resource> $readableStreams optional subset of readable streams
223     * @param bool $final whether this is the final drain operation
224     * @param ?LoggerInterface $logger logger used for chunk-level output events
225     */
226    public function drainOutput(
227        array $readableStreams = [],
228        bool $final = false,
229        ?LoggerInterface $logger = null,
230    ): void {
231        $this->outputTransport->drain(
232            workerPid: $this->pid,
233            readableStreams: $readableStreams,
234            final: $final,
235            logger: $logger,
236        );
237    }
238
239    /**
240     * Writes a stdout chunk into the transport connected to the parent process.
241     *
242     * @param string $chunk output chunk
243     */
244    public function writeOutput(string $chunk): void
245    {
246        $this->outputTransport->writeOutput($chunk);
247    }
248
249    /**
250     * Writes an error-output chunk into the transport connected to the parent process.
251     *
252     * @param string $chunk error output chunk
253     */
254    public function writeErrorOutput(string $chunk): void
255    {
256        $this->outputTransport->writeErrorOutput($chunk);
257    }
258
259    /**
260     * Closes the child-side transport resources.
261     *
262     * This method SHOULD be called once the worker has finished execution.
263     */
264    public function closeChildSide(): void
265    {
266        $this->outputTransport->closeChildSide();
267    }
268
269    /**
270     * Marks the worker as terminated and captures its final exit metadata.
271     *
272     * The method MUST update all relevant lifecycle fields and perform a final
273     * output drain before logging termination details.
274     *
275     * @param int $status raw process status returned by the operating system
276     * @param ?LoggerInterface $logger logger used for lifecycle events
277     */
278    public function markTerminated(int $status, ?LoggerInterface $logger = null): void
279    {
280        $this->running = false;
281        $this->status = $status;
282        $this->exitCode = null;
283        $this->terminationSignal = null;
284
285        if (pcntl_wifexited($status)) {
286            $this->exitCode = pcntl_wexitstatus($status);
287        } elseif (pcntl_wifsignaled($status)) {
288            $this->terminationSignal = Signal::tryFrom(pcntl_wtermsig($status));
289        }
290
291        $this->drainOutput(final: true, logger: $logger);
292
293        $logger?->info('Worker process terminated.', [
294            'worker_pid' => $this->pid,
295            'exit_code' => $this->exitCode,
296            'termination_signal' => $this->terminationSignal?->name,
297        ]);
298    }
299
300    /**
301     * Marks the worker as detached when it can no longer be waited on by the parent.
302     *
303     * This condition MAY occur if the process exits outside of the manager's control.
304     *
305     * @param ?LoggerInterface $logger logger used for lifecycle events
306     */
307    public function markDetached(?LoggerInterface $logger = null): void
308    {
309        $this->running = false;
310        $this->drainOutput(final: true, logger: $logger);
311
312        $logger?->warning('Worker process detached before wait completion.', [
313            'worker_pid' => $this->pid,
314        ]);
315    }
316}