@tinvar/renderer 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pool.js ADDED
@@ -0,0 +1,664 @@
1
+ // A pool of renderers: N sandboxed processes, one socket each, in one
2
+ // private directory that PHP reads as the pool.
3
+ //
4
+ // One renderer is one JavaScript thread, and under load that thread is what
5
+ // runs out: on a ten-core machine it sat at 100% of a core while FrankenPHP's
6
+ // ten workers used half of one between them, waiting on its socket (ADR
7
+ // 0028). More renderers is the remedy; how they are run is what this module
8
+ // decides.
9
+ //
10
+ // Each process here is exactly the renderer a single launch starts: its own
11
+ // permission-model sandbox and allowlist, the allowlisted environment, its
12
+ // own socket as the one path it may write, and a tether to this process
13
+ // that this process never reads -- its standard input, a pipe whose end
14
+ // here is held and left alone (spawnSandboxed). They share nothing -- no
15
+ // listening socket, no channel to each other -- so a renderer a dependency
16
+ // has compromised reaches no further than one renderer ever could. Node's
17
+ // cluster module was the other way to get N processes, and was turned down
18
+ // for that reason: its workers inherit the primary's whole environment, and
19
+ // they ask the unsandboxed primary to listen for them, at whatever path they
20
+ // name.
21
+ //
22
+ // PHP finds the renderers by listing the directory, so the set can change
23
+ // under it. A renderer that dies has its socket removed and a new one, under
24
+ // a new name, takes its place; a rolling restart starts each replacement
25
+ // before the renderer it replaces stops taking renders. A name is never
26
+ // reused, so no renderer can remove a successor's socket.
27
+
28
+ import fs from 'node:fs';
29
+ import net from 'node:net';
30
+ import path from 'node:path';
31
+ import { FrameReader, createCodec, encodeFrame } from './codec.js';
32
+ import { logLine } from './log.js';
33
+ import { FRAME } from './protocol.js';
34
+ import { DEFAULT_DRAIN_MS } from './server.js';
35
+
36
+ /** Names the supervisor, for renderer:stop and renderer:restart to signal. */
37
+ export const PID_FILE = 'renderer.pid';
38
+
39
+ export const DEFAULT_TIMINGS = Object.freeze({
40
+ // A renderer that has not answered a health check by then is not starting.
41
+ startMs: 20_000,
42
+ healthEveryMs: 2_000,
43
+ // Generous: a renderer at full load still answers within a few event-loop
44
+ // turns. One that misses twice running has a loop that is not turning --
45
+ // an infinite loop in app code -- and every render sent to it would wait
46
+ // out the deadline, so it is replaced.
47
+ healthTimeoutMs: 3_000,
48
+ unhealthyAfter: 2,
49
+ // Restarts back off from the first to the second while a renderer keeps
50
+ // dying young -- a build that throws on import would otherwise be started
51
+ // in a tight loop -- and start from the first again once one has lasted.
52
+ restartMinMs: 250,
53
+ restartMaxMs: 10_000,
54
+ stableAfterMs: 10_000,
55
+ drainMs: DEFAULT_DRAIN_MS,
56
+ // Past the drain, a renderer that has still not exited is killed.
57
+ killGraceMs: 5_000,
58
+ });
59
+
60
+ /**
61
+ * Asks the renderer at `socketPath` how it is, over the renderer protocol.
62
+ *
63
+ * @param {string} socketPath
64
+ * @param {{ codec?: string | object, timeoutMs?: number }} [options]
65
+ * @returns {Promise<{ pid: number, inFlight: number, served: number }>}
66
+ */
67
+ export function checkHealth(
68
+ socketPath,
69
+ { codec = 'msgpack', timeoutMs = DEFAULT_TIMINGS.healthTimeoutMs } = {},
70
+ ) {
71
+ const wire = typeof codec === 'string' ? createCodec(codec) : codec;
72
+ // An answer is a few numbers; anything near this size is not one.
73
+ const maxFrameBytes = 4096;
74
+
75
+ return new Promise((resolve, reject) => {
76
+ const socket = net.createConnection(socketPath);
77
+ const reader = new FrameReader(maxFrameBytes, wire);
78
+ let settled = false;
79
+ const settle = (error, value) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ clearTimeout(timer);
83
+ socket.destroy();
84
+ if (error) reject(error);
85
+ else resolve(value);
86
+ };
87
+ const timer = setTimeout(() => settle(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs);
88
+
89
+ socket.on('connect', () => socket.write(encodeFrame({ t: FRAME.HEALTH }, maxFrameBytes, wire)));
90
+ socket.on('data', (chunk) => {
91
+ let frames;
92
+ try {
93
+ frames = reader.push(new Uint8Array(chunk));
94
+ } catch (error) {
95
+ settle(error);
96
+ return;
97
+ }
98
+ const frame = frames[0];
99
+ if (frame === undefined) return;
100
+ // What the renderer says is checked, and nothing it says may throw:
101
+ // this is a data handler, outside any try, and a throw here takes the
102
+ // supervisor down. Number() or String() would -- a map whose valueOf
103
+ // and toString are not functions cannot be made either.
104
+ if (frame?.t !== FRAME.HEALTHY) {
105
+ const type = typeof frame?.t === 'string' ? frame.t : typeof frame?.t;
106
+ settle(new Error(`answered a health check with a '${type}' frame`));
107
+ return;
108
+ }
109
+ const whole = (value) => Number.isSafeInteger(value) && value >= 0;
110
+ if (!whole(frame.pid) || !whole(frame.inFlight) || !whole(frame.served)) {
111
+ settle(
112
+ new Error(
113
+ "answered a health check without a whole number in each of 'pid', 'inFlight' and 'served'",
114
+ ),
115
+ );
116
+ return;
117
+ }
118
+ settle(null, { pid: frame.pid, inFlight: frame.inFlight, served: frame.served });
119
+ });
120
+ socket.on('error', (error) => settle(error));
121
+ socket.on('close', () => settle(new Error('closed without answering')));
122
+ });
123
+ }
124
+
125
+ export class RendererPool {
126
+ #directory;
127
+ #size;
128
+ #spawn;
129
+ #codec;
130
+ #timings;
131
+ #log;
132
+ #socketGroup;
133
+ #slots = [];
134
+ /**
135
+ * Every renderer started and not yet exited: each slot's, those a rolling
136
+ * restart replaced that are still finishing their renders, and those
137
+ * still starting. A stop reaches all of them; tracked through the slots
138
+ * alone, one starting as a replacement was missed.
139
+ */
140
+ #live = new Set();
141
+ #sequence = 0;
142
+ #started = false;
143
+ #stopping = null;
144
+ #restarting = null;
145
+ #again = false;
146
+ #monitor = null;
147
+
148
+ /**
149
+ * @param {{
150
+ * directory: string,
151
+ * processes: number,
152
+ * spawn: (socketPath: string) => import('node:child_process').ChildProcess,
153
+ * codec?: string,
154
+ * timings?: Partial<Record<keyof typeof DEFAULT_TIMINGS, number>>,
155
+ * log?: { info: (line: string) => void, error: (line: string) => void },
156
+ * socketGroup?: number | null,
157
+ * }} options `spawn` starts one renderer listening on the path it is given;
158
+ * `socketGroup` is the group the directory is shared with, as each
159
+ * renderer shares its socket (src/socket-group.js)
160
+ */
161
+ constructor({ directory, processes, spawn, codec = 'msgpack', timings = {}, log, socketGroup = null }) {
162
+ this.#directory = directory;
163
+ this.#socketGroup = socketGroup;
164
+ this.#size = processes;
165
+ this.#spawn = spawn;
166
+ this.#codec = codec;
167
+ this.#timings = { ...DEFAULT_TIMINGS, ...timings };
168
+ // JSON lines, as a render's are, redacted and with the supervisor's pid
169
+ // (ADR 0112): one shipper reads every line the renderer writes.
170
+ this.#log = log ?? {
171
+ info: (line) => logLine('info', line, { component: 'pool' }),
172
+ error: (line) => logLine('error', line, { component: 'pool' }),
173
+ };
174
+ }
175
+
176
+ /** The sockets of the renderers taking renders now. */
177
+ get sockets() {
178
+ return this.#slots.map((slot) => slot.current?.socketPath).filter(Boolean);
179
+ }
180
+
181
+ /** The pids of the renderers taking renders now, by slot. */
182
+ get pids() {
183
+ return this.#slots.map((slot) => slot.current?.child.pid ?? null);
184
+ }
185
+
186
+ /**
187
+ * Starts every renderer and resolves once each has answered a health
188
+ * check. If any fails to start, the rest are stopped and this rejects:
189
+ * a pool that could not start whole is a build or a setup that is wrong.
190
+ * Stopped before it is up, it resolves once the renderers are ready or gone.
191
+ */
192
+ async start() {
193
+ prepareDirectory(this.#directory, { socketGroup: this.#socketGroup });
194
+
195
+ this.#slots = Array.from({ length: this.#size }, (_, index) => ({
196
+ index,
197
+ current: null,
198
+ delay: 0,
199
+ timer: null,
200
+ }));
201
+ for (const slot of this.#slots) slot.current = this.#launch(slot);
202
+
203
+ const ready = await Promise.all(this.#slots.map((slot) => this.#ready(slot.current)));
204
+ // Stopped while starting: the renderers left because they were asked
205
+ // to, and the stop under way is the one that ends them.
206
+ if (this.#stopping) return;
207
+ if (ready.includes(false)) {
208
+ await this.stop({ graceful: false });
209
+ const index = ready.indexOf(false);
210
+ throw new Error(`renderer ${index} did not start; its output is above.`);
211
+ }
212
+
213
+ this.#started = true;
214
+ this.#monitor = setInterval(() => this.#checkAll(), this.#timings.healthEveryMs);
215
+ this.#log.info(`a pool of ${this.#size} listening in ${this.#directory}`);
216
+ }
217
+
218
+ /**
219
+ * Replaces every renderer, one at a time, without refusing a render: each
220
+ * replacement is started and answering before the renderer it replaces
221
+ * stops taking renders and finishes the ones it has. For a deploy -- the
222
+ * replacements load the build as it is now.
223
+ *
224
+ * Stops at the first replacement that does not start, leaving the pool on
225
+ * the renderers it had: a broken build is not rolled out over a working one.
226
+ *
227
+ * @returns {Promise<boolean>} whether every renderer was replaced
228
+ */
229
+ restart() {
230
+ if (this.#stopping || !this.#started) return Promise.resolve(false);
231
+ if (this.#restarting) {
232
+ // Asked again mid-way: the build may have changed since this one
233
+ // began, so the whole pool goes round once more when it ends.
234
+ this.#again = true;
235
+ return this.#restarting;
236
+ }
237
+
238
+ this.#restarting = (async () => {
239
+ do {
240
+ this.#again = false;
241
+ for (const slot of this.#slots) {
242
+ if (this.#stopping) return false;
243
+ const replaced = await this.#replace(slot);
244
+ // Stopped meanwhile: the replacement was stopped with the rest.
245
+ if (this.#stopping) return false;
246
+ if (!replaced) {
247
+ this.#log.error(
248
+ `rolling restart stopped: the new renderer ${slot.index} did not start, so the pool keeps the renderers it had.`,
249
+ );
250
+ return false;
251
+ }
252
+ }
253
+ } while (this.#again && !this.#stopping);
254
+ this.#log.info(`rolling restart done: ${this.#size} renderers replaced`);
255
+ return true;
256
+ })().finally(() => {
257
+ this.#restarting = null;
258
+ });
259
+
260
+ return this.#restarting;
261
+ }
262
+
263
+ /**
264
+ * Stops the pool. Graceful: every renderer stops taking renders and
265
+ * finishes those it has, within the drain time. Otherwise at once: the
266
+ * renders in flight are cut off, and PHP reports each as incomplete.
267
+ *
268
+ * A graceful stop already under way is made immediate by asking for one
269
+ * that is not; asked for again as graceful, it is the same stop.
270
+ */
271
+ stop({ graceful = true } = {}) {
272
+ if (this.#stopping && graceful) return this.#stopping.done;
273
+ if (this.#stopping?.graceful === false) return this.#stopping.done;
274
+
275
+ clearInterval(this.#monitor);
276
+ for (const slot of this.#slots) clearTimeout(slot.timer);
277
+
278
+ const running = [...this.#live].filter((p) => !p.gone);
279
+ const signal = graceful ? 'SIGTERM' : 'SIGKILL';
280
+ for (const proc of running) proc.child.kill(signal);
281
+
282
+ const limit = graceful ? this.#timings.drainMs + this.#timings.killGraceMs : this.#timings.killGraceMs;
283
+ const done = Promise.all(running.map((proc) => this.#gone(proc, limit))).then(() => {
284
+ for (const proc of running) removeSocket(proc.socketPath);
285
+ removePidFile(this.#directory);
286
+ if (this.#started) this.#log.info(graceful ? 'pool drained and stopped' : 'pool stopped');
287
+ });
288
+
289
+ this.#stopping = { graceful, done };
290
+ return done;
291
+ }
292
+
293
+ /** Starts a renderer for `slot` on a socket no renderer has used. */
294
+ #launch(slot) {
295
+ const socketPath = path.join(this.#directory, `renderer-${slot.index}-${++this.#sequence}.sock`);
296
+ const proc = {
297
+ socketPath,
298
+ child: null,
299
+ startedAt: Date.now(),
300
+ failures: 0,
301
+ checking: false,
302
+ ready: false,
303
+ gone: false,
304
+ };
305
+ proc.exited = new Promise((resolve) => {
306
+ proc.markGone = resolve;
307
+ });
308
+
309
+ try {
310
+ proc.child = this.#spawn(socketPath);
311
+ } catch (error) {
312
+ this.#log.error(`could not start renderer ${slot.index}: ${error.message}`);
313
+ proc.gone = true;
314
+ proc.markGone();
315
+ queueMicrotask(() => this.#exited(slot, proc, null, null));
316
+ return proc;
317
+ }
318
+
319
+ this.#live.add(proc);
320
+
321
+ // An 'error' with no 'exit' after it is a process that never started.
322
+ proc.child.on('error', (error) => {
323
+ this.#log.error(`renderer ${slot.index}: ${error.message}`);
324
+ if (proc.child.pid === undefined) this.#exited(slot, proc, null, null);
325
+ });
326
+ proc.child.once('exit', (code, signal) => this.#exited(slot, proc, code, signal));
327
+
328
+ return proc;
329
+ }
330
+
331
+ /** Whether `proc` is listening and answering, within the start time. */
332
+ async #ready(proc) {
333
+ const deadline = Date.now() + this.#timings.startMs;
334
+ while (!proc.gone && Date.now() < deadline) {
335
+ if (fs.existsSync(proc.socketPath)) {
336
+ try {
337
+ const answer = await checkHealth(proc.socketPath, {
338
+ codec: this.#codec,
339
+ timeoutMs: this.#timings.healthTimeoutMs,
340
+ });
341
+ // Only the renderer started here counts. Anything of this user's
342
+ // can listen at a path in the directory -- a compromised sibling
343
+ // at the next socket's name, before the renderer is up -- and an
344
+ // answer from it would pass a replacement that never started.
345
+ if (answer.pid === proc.child.pid) {
346
+ proc.ready = true;
347
+ return true;
348
+ }
349
+ } catch {
350
+ // Listening but not answering yet: asked again below.
351
+ }
352
+ }
353
+ await sleep(50);
354
+ }
355
+ if (!proc.gone) proc.child?.kill('SIGKILL');
356
+
357
+ return false;
358
+ }
359
+
360
+ /** One slot of a rolling restart. */
361
+ async #replace(slot) {
362
+ const old = slot.current;
363
+ const next = this.#launch(slot);
364
+ if (!(await this.#ready(next)) || this.#stopping) return false;
365
+
366
+ // A restart scheduled for the old renderer, had it just died, is
367
+ // answered by this one. One that already ran -- the old renderer died
368
+ // while this one was starting -- made a renderer of its own the slot's;
369
+ // left there, it was overwritten below and ran on outside the pool,
370
+ // its socket still listed. It makes way too.
371
+ clearTimeout(slot.timer);
372
+ const displaced = slot.current;
373
+ slot.current = next;
374
+ const retiring = [...new Set([old, displaced])].filter((proc) => proc && !proc.gone);
375
+ for (const proc of retiring) {
376
+ proc.retired = true;
377
+ // It stops taking renders now and removes its socket; PHP has the
378
+ // replacement's already, so nothing waits on the gap.
379
+ proc.child.kill('SIGTERM');
380
+ }
381
+ await Promise.all(
382
+ retiring.map((proc) => this.#gone(proc, this.#timings.drainMs + this.#timings.killGraceMs)),
383
+ );
384
+ this.#log.info(`renderer ${slot.index} replaced (pid ${old?.child?.pid ?? '-'} -> ${next.child.pid})`);
385
+
386
+ return true;
387
+ }
388
+
389
+ #exited(slot, proc, code, signal) {
390
+ if (proc.reported) return;
391
+ proc.reported = true;
392
+ proc.gone = true;
393
+ proc.markGone();
394
+ this.#live.delete(proc);
395
+
396
+ // Its socket now leads nowhere: removed, PHP stops choosing it at once,
397
+ // rather than after a refused connection each.
398
+ removeSocket(proc.socketPath);
399
+
400
+ // Replaced, stopped on purpose, or a replacement that never became the
401
+ // slot's renderer: nothing to start in its place.
402
+ if (proc.retired || this.#stopping || !this.#started || slot.current !== proc) return;
403
+
404
+ const lived = Date.now() - proc.startedAt;
405
+ slot.delay =
406
+ lived >= this.#timings.stableAfterMs || slot.delay === 0
407
+ ? this.#timings.restartMinMs
408
+ : Math.min(this.#timings.restartMaxMs, slot.delay * 2);
409
+
410
+ this.#log.error(
411
+ `renderer ${slot.index} (pid ${proc.child?.pid ?? '-'}) exited with ${signal ?? `status ${code}`}; ` +
412
+ `starting another in ${slot.delay} ms`,
413
+ );
414
+
415
+ // Never at once, even the first time: a Ctrl-C reaches a whole process
416
+ // group, and a renderer may exit on it a moment before this process
417
+ // hears of it. Restarting then would start a renderer on the way down.
418
+ slot.timer = setTimeout(() => this.#respawn(slot, proc), slot.delay);
419
+ }
420
+
421
+ async #respawn(slot, dead) {
422
+ if (this.#stopping || slot.current !== dead) return;
423
+
424
+ const proc = this.#launch(slot);
425
+ slot.current = proc;
426
+ if (await this.#ready(proc)) {
427
+ this.#log.info(`renderer ${slot.index} restarted (pid ${proc.child.pid})`);
428
+ }
429
+ // Not ready: it was killed or died, and its exit schedules the next try.
430
+ }
431
+
432
+ #checkAll() {
433
+ for (const slot of this.#slots) {
434
+ const proc = slot.current;
435
+ if (!proc || !proc.ready || proc.gone || proc.checking) continue;
436
+
437
+ proc.checking = true;
438
+ checkHealth(proc.socketPath, { codec: this.#codec, timeoutMs: this.#timings.healthTimeoutMs })
439
+ .then((answer) => {
440
+ if (answer.pid !== proc.child.pid) throw new Error(`pid ${answer.pid} answered on its socket`);
441
+ })
442
+ .then(
443
+ () => {
444
+ proc.failures = 0;
445
+ },
446
+ (error) => {
447
+ if (proc.gone || this.#stopping) return;
448
+ proc.failures++;
449
+ if (proc.failures >= this.#timings.unhealthyAfter) {
450
+ this.#log.error(
451
+ `renderer ${slot.index} (pid ${proc.child.pid}) failed ${proc.failures} health checks running ` +
452
+ `(${error.message}); replacing it`,
453
+ );
454
+ proc.child.kill('SIGKILL');
455
+ }
456
+ },
457
+ )
458
+ .finally(() => {
459
+ proc.checking = false;
460
+ });
461
+ }
462
+ }
463
+
464
+ /** Resolves once `proc` has exited, killing it if it is still there after `limitMs`. */
465
+ async #gone(proc, limitMs) {
466
+ if (proc.gone) return;
467
+ let timer;
468
+ const late = new Promise((resolve) => {
469
+ timer = setTimeout(() => resolve('late'), limitMs);
470
+ });
471
+ if ((await Promise.race([proc.exited, late])) === 'late') {
472
+ proc.child.kill('SIGKILL');
473
+ await proc.exited;
474
+ }
475
+ clearTimeout(timer);
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Runs a pool until it is told to stop, and resolves with the exit status.
481
+ *
482
+ * SIGTERM, SIGINT, SIGHUP graceful stop
483
+ * SIGQUIT immediate stop, even during a graceful one
484
+ * SIGUSR2 rolling restart, for a deploy
485
+ *
486
+ * A second SIGTERM or SIGINT does not hurry a drain. Under `tinvar serve` a
487
+ * terminal's Ctrl-C reaches this process and serve together, and serve then
488
+ * sends a SIGTERM of its own: one stop, asked for twice, while FrankenPHP
489
+ * finishes the requests whose renders are draining here.
490
+ *
491
+ * SIGUSR1 is not used: a listener for it is how the launcher keeps the
492
+ * debugger shut, and it must stay that and nothing else.
493
+ *
494
+ * @param {ConstructorParameters<typeof RendererPool>[0]} options
495
+ */
496
+ export async function runPool(options) {
497
+ const pool = new RendererPool(options);
498
+
499
+ let resolveStopped;
500
+ const stopped = new Promise((resolve) => {
501
+ resolveStopped = resolve;
502
+ });
503
+ const stop = (graceful) => pool.stop({ graceful }).then(() => resolveStopped(0));
504
+
505
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => stop(true));
506
+ process.on('SIGQUIT', () => stop(false));
507
+ process.on('SIGUSR2', () => {
508
+ pool.restart();
509
+ });
510
+
511
+ try {
512
+ await pool.start();
513
+ } catch (error) {
514
+ logLine('error', 'the pool did not start', { component: 'pool' }, error);
515
+ return 1;
516
+ }
517
+
518
+ return stopped;
519
+ }
520
+
521
+ /**
522
+ * The pool's directory: made if missing, and refused if anyone but this user
523
+ * could write it. PHP lists it to find renderers, so whoever could add a
524
+ * socket to it would be sent renders.
525
+ *
526
+ * With a socket group -- the renderers run as a user of their own, and PHP
527
+ * as another in the group -- it is given to the group and made 0750: PHP
528
+ * may list it, and only this user may write it. A directory a service
529
+ * manager made for the pool (systemd's RuntimeDirectory=) is taken as it is
530
+ * and set the same way.
531
+ *
532
+ * @param {string} directory
533
+ * @param {{ socketGroup?: number | null }} [options]
534
+ */
535
+ export function prepareDirectory(directory, { socketGroup = null } = {}) {
536
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
537
+
538
+ const stat = fs.lstatSync(directory);
539
+ if (!stat.isDirectory()) {
540
+ throw new Error(`${directory} is not a directory, so it cannot hold a renderer pool.`);
541
+ }
542
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
543
+ throw new Error(
544
+ `${directory} belongs to another user; a renderer pool's directory must be this user's own.`,
545
+ );
546
+ }
547
+ if ((stat.mode & 0o022) !== 0) {
548
+ throw new Error(
549
+ `${directory} is writable by other users, who could add a renderer of their own to the pool; ` +
550
+ 'use a directory only this user can write (mode 700 or 755).',
551
+ );
552
+ }
553
+
554
+ if (socketGroup !== null) {
555
+ // Given to the group before it is opened to it.
556
+ fs.chownSync(directory, -1, socketGroup);
557
+ fs.chmodSync(directory, 0o750);
558
+ const shared = fs.lstatSync(directory);
559
+ if (shared.gid !== socketGroup || (shared.mode & 0o777) !== 0o750) {
560
+ throw new Error(
561
+ `${directory} could not be shared with group ${socketGroup} as 0750; it is ` +
562
+ `${(shared.mode & 0o777).toString(8)} in group ${shared.gid}.`,
563
+ );
564
+ }
565
+ }
566
+
567
+ // Readable by the group too, when there is one, so renderer:check run as
568
+ // PHP's user can name the supervisor; it can still signal none of it.
569
+ claimPidFile(directory, socketGroup === null ? 0o600 : 0o640);
570
+
571
+ // Sockets a pool that was killed outright left behind: nothing answers on
572
+ // them, and PHP would try each before finding the live ones. Only once
573
+ // the directory is this pool's, or a pool starting beside it would lose
574
+ // its sockets as stale.
575
+ for (const name of fs.readdirSync(directory)) {
576
+ const entry = path.join(directory, name);
577
+ if (name.endsWith('.sock') && fs.lstatSync(entry).isSocket()) fs.rmSync(entry);
578
+ }
579
+ }
580
+
581
+ /**
582
+ * Makes the directory this process's pool's, or throws if a live pool has
583
+ * it. The pid file is written whole and linked into place, so a reader
584
+ * never sees half a pid -- and linked rather than renamed, since a link
585
+ * refuses a file already there: checked first and written after, two pools
586
+ * started at the same moment both found none and both ran. One left by a
587
+ * pool killed outright is removed and the claim made again. (Two pools that
588
+ * both find such a file can still race in the moment between reading it
589
+ * and removing it.)
590
+ */
591
+ function claimPidFile(directory, mode) {
592
+ const pidFile = path.join(directory, PID_FILE);
593
+ const temporary = `${pidFile}.${process.pid}`;
594
+ fs.writeFileSync(temporary, `${process.pid}\n`, { mode });
595
+ fs.chmodSync(temporary, mode);
596
+ try {
597
+ for (;;) {
598
+ try {
599
+ fs.linkSync(temporary, pidFile);
600
+ return;
601
+ } catch (error) {
602
+ if (error.code !== 'EEXIST') throw error;
603
+ }
604
+ const other = readPid(pidFile);
605
+ if (other !== null && other !== process.pid && running(other)) {
606
+ throw new Error(`a renderer pool (pid ${other}) is already running in ${directory}.`);
607
+ }
608
+ fs.rmSync(pidFile, { force: true });
609
+ }
610
+ } finally {
611
+ fs.rmSync(temporary, { force: true });
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Removes what a renderer leaves at its socket path, and never throws: it
617
+ * runs in exit handlers, where a throw takes this process down, and every
618
+ * renderer with it.
619
+ *
620
+ * A renderer may write that one path, so it may leave a directory there
621
+ * instead of a socket -- an empty one: the sandbox refuses it anything
622
+ * beneath. rm() without `recursive` refuses a directory; it goes as one.
623
+ */
624
+ function removeSocket(socketPath) {
625
+ try {
626
+ fs.rmSync(socketPath, { force: true });
627
+ } catch {
628
+ try {
629
+ fs.rmdirSync(socketPath);
630
+ } catch {
631
+ // Not empty, or not ours to remove: left where it is. PHP is refused
632
+ // by it, and passes over it.
633
+ }
634
+ }
635
+ }
636
+
637
+ /** The pid in `file`, or null if there is none to read. */
638
+ export function readPid(file) {
639
+ try {
640
+ const text = fs.readFileSync(file, 'utf8').trim();
641
+ return /^[1-9][0-9]{0,9}$/.test(text) ? Number(text) : null;
642
+ } catch {
643
+ return null;
644
+ }
645
+ }
646
+
647
+ function removePidFile(directory) {
648
+ const pidFile = path.join(directory, PID_FILE);
649
+ if (readPid(pidFile) === process.pid) fs.rmSync(pidFile, { force: true });
650
+ }
651
+
652
+ /** Signal 0 asks without sending; EPERM is a process that exists under another user. */
653
+ function running(pid) {
654
+ try {
655
+ process.kill(pid, 0);
656
+ return true;
657
+ } catch (error) {
658
+ return error.code === 'EPERM';
659
+ }
660
+ }
661
+
662
+ function sleep(ms) {
663
+ return new Promise((resolve) => setTimeout(resolve, ms));
664
+ }