-
Notifications
You must be signed in to change notification settings - Fork 233
/
php-process-manager.ts
232 lines (218 loc) · 6.6 KB
/
php-process-manager.ts
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { AcquireTimeoutError, Semaphore } from '@php-wasm/util';
import { PHP } from './php';
export type PHPFactoryOptions = {
isPrimary: boolean;
};
export type PHPFactory = (options: PHPFactoryOptions) => Promise<PHP>;
export interface ProcessManagerOptions {
/**
* The maximum number of PHP instances that can exist at
* the same time.
*/
maxPhpInstances?: number;
/**
* The number of milliseconds to wait for a PHP instance when
* we have reached the maximum number of PHP instances and
* cannot spawn a new one. If the timeout is reached, we assume
* all the PHP instances are deadlocked and a throw MaxPhpInstancesError.
*
* Default: 5000
*/
timeout?: number;
/**
* The primary PHP instance that's never killed. This instance
* contains the reference filesystem used by all other PHP instances.
*/
primaryPhp?: PHP;
/**
* A factory function used for spawning new PHP instances.
*/
phpFactory?: PHPFactory;
}
export interface SpawnedPHP {
php: PHP;
reap: () => void;
}
export class MaxPhpInstancesError extends Error {
constructor(limit: number) {
super(
`Requested more concurrent PHP instances than the limit (${limit}).`
);
this.name = this.constructor.name;
}
}
/**
* A PHP Process manager.
*
* Maintains:
* * A single "primary" PHP instance that's never killed – it contains the
* reference filesystem used by all other PHP instances.
* * A pool of disposable PHP instances that are spawned to handle a single
* request and reaped immediately after.
*
* When a new request comes in, PHPProcessManager yields the idle instance to handle it,
* and immediately starts initializing a new idle instance. In other words, for n concurrent
* requests, there are at most n+1 PHP instances running at the same time.
*
* A slight nuance is that the first idle instance is not initialized until the first
* concurrent request comes in. This is because many use-cases won't involve parallel
* requests and, for those, we can avoid eagerly spinning up a second PHP instance.
*
* This strategy is inspired by Cowboy, an Erlang HTTP server. Handling a single extra
* request can happen immediately, while handling multiple extra requests requires
* extra time to spin up a few PHP instances. This is a more resource-friendly tradeoff
* than keeping 5 idle instances at all times.
*/
export class PHPProcessManager implements AsyncDisposable {
private primaryPhp?: PHP;
private primaryIdle = true;
private nextInstance: Promise<SpawnedPHP> | null = null;
/**
* All spawned PHP instances, including the primary PHP instance.
* Used for bookkeeping and reaping all instances on dispose.
*/
private allInstances: Promise<SpawnedPHP>[] = [];
private phpFactory?: PHPFactory;
private maxPhpInstances: number;
private semaphore: Semaphore;
constructor(options?: ProcessManagerOptions) {
this.maxPhpInstances = options?.maxPhpInstances ?? 5;
this.phpFactory = options?.phpFactory;
this.primaryPhp = options?.primaryPhp;
this.semaphore = new Semaphore({
concurrency: this.maxPhpInstances,
/**
* Wait up to 5 seconds for resources to become available
* before assuming that all the PHP instances are deadlocked.
*/
timeout: options?.timeout || 5000,
});
}
/**
* Get the primary PHP instance.
*
* If the primary PHP instance is not set, it will be spawned
* using the provided phpFactory.
*
* @throws {Error} when called twice before the first call is resolved.
*/
async getPrimaryPhp() {
if (!this.phpFactory && !this.primaryPhp) {
throw new Error(
'phpFactory or primaryPhp must be set before calling getPrimaryPhp().'
);
} else if (!this.primaryPhp) {
const spawned = await this.spawn!({ isPrimary: true });
this.primaryPhp = spawned.php;
}
return this.primaryPhp!;
}
/**
* Get a PHP instance.
*
* It could be either the primary PHP instance, an idle disposable PHP instance,
* or a newly spawned PHP instance – depending on the resource availability.
*
* @throws {MaxPhpInstancesError} when the maximum number of PHP instances is reached
* and the waiting timeout is exceeded.
*/
async acquirePHPInstance(): Promise<SpawnedPHP> {
if (this.primaryIdle) {
this.primaryIdle = false;
return {
php: await this.getPrimaryPhp(),
reap: () => (this.primaryIdle = true),
};
}
/**
* nextInstance is null:
*
* * Before the first concurrent getInstance() call
* * When the last getInstance() call did not have enough
* budget left to optimistically start spawning the next
* instance.
*/
const spawnedPhp =
this.nextInstance || this.spawn({ isPrimary: false });
/**
* Start spawning the next instance if there's still room. We can't
* just always spawn the next instance because spawn() can fail
* asynchronously and then we'll get an unhandled promise rejection.
*/
if (this.semaphore.remaining > 0) {
this.nextInstance = this.spawn({ isPrimary: false });
} else {
this.nextInstance = null;
}
return await spawnedPhp;
}
/**
* Initiated spawning of a new PHP instance.
* This function is synchronous on purpose – it needs to synchronously
* add the spawn promise to the allInstances array without waiting
* for PHP to spawn.
*/
private spawn(factoryArgs: PHPFactoryOptions): Promise<SpawnedPHP> {
if (factoryArgs.isPrimary && this.allInstances.length > 0) {
throw new Error(
'Requested spawning a primary PHP instance when another primary instance already started spawning.'
);
}
const spawned = this.doSpawn(factoryArgs);
this.allInstances.push(spawned);
const pop = () => {
this.allInstances = this.allInstances.filter(
(instance) => instance !== spawned
);
};
return spawned
.catch((rejection) => {
pop();
throw rejection;
})
.then((result) => ({
...result,
reap: () => {
pop();
result.reap();
},
}));
}
/**
* Actually acquires the lock and spawns a new PHP instance.
*/
private async doSpawn(factoryArgs: PHPFactoryOptions): Promise<SpawnedPHP> {
let release: () => void;
try {
release = await this.semaphore.acquire();
} catch (error) {
if (error instanceof AcquireTimeoutError) {
throw new MaxPhpInstancesError(this.maxPhpInstances);
}
throw error;
}
try {
const php = await this.phpFactory!(factoryArgs);
return {
php,
reap() {
php.exit();
release();
},
};
} catch (e) {
release();
throw e;
}
}
async [Symbol.asyncDispose]() {
if (this.primaryPhp) {
this.primaryPhp.exit();
}
await Promise.all(
this.allInstances.map((instance) =>
instance.then(({ reap }) => reap())
)
);
}
}