@tinvar/renderer 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/bin/dev-renderer.js +117 -0
- package/bin/renderer-probe.js +52 -0
- package/bin/renderer.js +327 -0
- package/package.json +44 -4
- package/src/asset-origin.js +306 -0
- package/src/build-profile.js +108 -0
- package/src/can-branch.js +1666 -0
- package/src/classify.js +157 -0
- package/src/client-chunks.js +54 -0
- package/src/client-props.js +540 -0
- package/src/codec.js +297 -0
- package/src/dev-app.js +45 -0
- package/src/dev-guard.js +33 -0
- package/src/dev-server.js +460 -0
- package/src/eslint.js +469 -0
- package/src/head.js +524 -0
- package/src/html-stream.js +479 -0
- package/src/log.js +131 -0
- package/src/mint-client.js +142 -0
- package/src/panel-styles.js +254 -0
- package/src/pool.js +664 -0
- package/src/protocol.js +71 -0
- package/src/query-client.js +198 -0
- package/src/query-map.js +116 -0
- package/src/render-session.js +580 -0
- package/src/sandbox.js +710 -0
- package/src/server.js +466 -0
- package/src/socket-group.js +72 -0
- package/src/vite.js +2416 -0
- package/src/vitest.d.ts +20 -0
- package/src/vitest.js +179 -0
- package/src/warm.d.ts +9 -0
- package/src/warm.js +60 -0
- package/src/workbench.d.ts +146 -0
- package/src/workbench.js +754 -0
- package/README.md +0 -3
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
|
+
}
|