forked from phpstan/phpstan-src
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathForkedProcessPromise.php
More file actions
179 lines (152 loc) · 4.54 KB
/
Copy pathForkedProcessPromise.php
File metadata and controls
179 lines (152 loc) · 4.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
<?php declare(strict_types = 1);
namespace PHPStan\Process;
use PHPStan\Command\FixerWorkerRunner;
use PHPStan\Command\Output;
use PHPStan\ShouldNotHappenException;
use React\EventLoop\LoopInterface;
use React\EventLoop\TimerInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use React\Socket\TcpServer;
use Symfony\Component\Console\Input\InputInterface;
use function fclose;
use function pcntl_fork;
use function pcntl_waitpid;
use function pcntl_wexitstatus;
use function pcntl_wifexited;
use function posix_kill;
use function rewind;
use function stream_get_contents;
use function tmpfile;
use const SIGTERM;
use const WNOHANG;
/**
* ProcessPromise backed by pcntl_fork(): the PHPStan Pro worker is forked from
* the already-booted main process, so it inherits the DI container for free
* and skips the application re-boot that a {@see SpawnedProcessPromise} pays.
*
* The forked child still talks to FixerApplication over the same TCP + NDJSON
* protocol — only the process-creation mechanism differs.
*/
final class ForkedProcessPromise implements ProcessPromise
{
private const WAITPID_POLL_INTERVAL = 0.01;
/** @var Deferred<string> */
private Deferred $deferred;
private ?int $childPid = null;
/** @var resource|null */
private $stdOut = null;
private ?TimerInterface $waitTimer = null;
private bool $canceled = false;
/**
* @param string[] $inceptionFiles
* @param mixed[]|null $projectConfigArray
*/
public function __construct(
private LoopInterface $loop,
private FixerWorkerRunner $fixerWorkerRunner,
private TcpServer $server,
private Output $errorOutput,
private array $inceptionFiles,
private bool $isOnlyFiles,
private ?array $projectConfigArray,
private ?string $configuration,
private int $serverPort,
private InputInterface $input,
)
{
$this->deferred = new Deferred(function (): void {
$this->cancel();
});
}
/**
* @return PromiseInterface<string>
*/
public function run(): PromiseInterface
{
// Created before the fork so the parent can read what the child wrote.
$tmpStdOut = tmpfile();
if ($tmpStdOut === false) {
throw new ShouldNotHappenException('Failed creating temp file for stdout.');
}
$this->stdOut = $tmpStdOut;
$pid = pcntl_fork();
if ($pid === -1) {
fclose($this->stdOut);
$this->stdOut = null;
// Deferred so it runs after FixerApplication has stored the promise.
$this->loop->futureTick(function (): void {
$this->deferred->reject(new ProcessCrashedException('pcntl_fork() failed.'));
});
return $this->deferred->promise();
}
if ($pid === 0) {
// Child: drop the inherited listening socket immediately, then run
// the worker on its own fresh event loop and never return.
$this->server->close();
$exitCode = $this->fixerWorkerRunner->run(
$this->errorOutput,
$this->inceptionFiles,
$this->isOnlyFiles,
$this->projectConfigArray,
$this->configuration,
$this->serverPort,
$this->input,
);
exit($exitCode);
}
// Parent: poll for the child to exit and resolve/reject accordingly.
$this->childPid = $pid;
$this->waitTimer = $this->loop->addPeriodicTimer(self::WAITPID_POLL_INTERVAL, function () use ($pid): void {
$status = 0;
$result = pcntl_waitpid($pid, $status, WNOHANG);
if ($result === 0) {
return;
}
$this->cancelWaitTimer();
$output = '';
if ($this->stdOut !== null) {
rewind($this->stdOut);
$output = (string) stream_get_contents($this->stdOut);
fclose($this->stdOut);
$this->stdOut = null;
}
if ($this->canceled) {
// cancel() already rejected the promise; just reap the child.
return;
}
$exitCode = null;
if ($result > 0 && pcntl_wifexited($status)) {
$exitStatus = pcntl_wexitstatus($status);
if ($exitStatus !== false) {
$exitCode = $exitStatus;
}
}
if ($exitCode === 0) {
$this->deferred->resolve($output);
return;
}
$this->deferred->reject(new ProcessCrashedException($output));
});
return $this->deferred->promise();
}
private function cancel(): void
{
if ($this->childPid === null) {
throw new ShouldNotHappenException('Cancelling process before running');
}
$this->canceled = true;
// SIGTERM the child; the waitpid poll timer keeps running so it still
// gets reaped (otherwise: zombie).
posix_kill($this->childPid, SIGTERM);
$this->deferred->reject(new ProcessCanceledException());
}
private function cancelWaitTimer(): void
{
if ($this->waitTimer === null) {
return;
}
$this->loop->cancelTimer($this->waitTimer);
$this->waitTimer = null;
}
}