@thomas.f/prnext 0.1.11-alpha → 0.1.12-alpha
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/README.md +139 -2
- package/build/cache-handlers.mjs +4 -1
- package/build/compiler.mjs +1 -0
- package/build/config.mjs +11 -0
- package/build/index.mjs +7 -1
- package/build/npm-packages.mjs +12 -1
- package/build/runtime-contract.mjs +16 -0
- package/build/typescript-check.mjs +20 -0
- package/build/typescript.mjs +49 -0
- package/build/worker-retirement.mjs +14 -0
- package/cli.mjs +1 -0
- package/compat/index.d.ts +10 -0
- package/package.json +25 -9
- package/release/verify.mjs +9 -0
- package/runtime/app-render.mjs +95 -29
- package/runtime/app-stream-html.mjs +1 -52
- package/runtime/html-envelope.mjs +73 -0
- package/runtime/http.mjs +2 -2
- package/runtime/profiles.json +13 -8
- package/runtime/request-work.mjs +2 -2
- package/runtime/rsc-lifecycle.mjs +119 -0
- package/runtime/rsc-work-watchdog.mjs +75 -0
- package/runtime/rsc-worker.mjs +9 -5
- package/runtime/transport.mjs +73 -53
- package/runtime/worker.mjs +90 -25
package/runtime/rsc-worker.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { partialModel, isPartialHole, boundedPartialFlight } from './app-partial
|
|
|
26
26
|
import { PartialTemplates, canResumePartialDirectly, partialTemplateReuse } from './partial-template.mjs';
|
|
27
27
|
|
|
28
28
|
installFetchCache();
|
|
29
|
+
if (process.env.PRNEXT_INSPECT_DIR) parentPort.postMessage({type:'rsc-ready'});
|
|
29
30
|
|
|
30
31
|
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
31
32
|
const routeEntries = new Map();
|
|
@@ -380,18 +381,19 @@ async function handleRequest(request, channel = null) {
|
|
|
380
381
|
|
|
381
382
|
const streamChannels = new Map();
|
|
382
383
|
const activeRequests = new Set();
|
|
383
|
-
const cancelledRequests = new Set();
|
|
384
384
|
const manifests = new Map();
|
|
385
385
|
const artifacts = new Map();
|
|
386
|
-
parentPort.on('message', async ({ id, request, type, value }) => {
|
|
386
|
+
parentPort.on('message', async ({ id, request, type, value, token }) => {
|
|
387
|
+
// Only the host arms these probes, while completed responses still have
|
|
388
|
+
// tracked cache work. There is no timer or heartbeat in an idle RSC isolate.
|
|
389
|
+
if (type === 'work-probe') { parentPort.postMessage({ type: 'work-responsive', token }); return; }
|
|
387
390
|
if (type === 'metadata') { manifests.set(id, value); return; }
|
|
388
391
|
if (type === 'metadata-drop') { manifests.delete(id); return; }
|
|
389
392
|
if (type === 'artifact') { artifacts.set(id, value); return; }
|
|
390
393
|
if (type === 'artifact-drop') { artifacts.delete(id); return; }
|
|
391
394
|
if (type === 'credit') { streamChannels.get(id)?.credit(); return; }
|
|
392
395
|
if (type === 'cancel') {
|
|
393
|
-
if (activeRequests.has(id))
|
|
394
|
-
else parentPort.postMessage({ id, type: 'settled' });
|
|
396
|
+
if (!activeRequests.has(id)) parentPort.postMessage({ id, type: 'settled' });
|
|
395
397
|
streamChannels.get(id)?.cancel(); return;
|
|
396
398
|
}
|
|
397
399
|
for (const field of ['clientModules', 'actions']) {
|
|
@@ -421,6 +423,8 @@ parentPort.on('message', async ({ id, request, type, value }) => {
|
|
|
421
423
|
} });
|
|
422
424
|
} finally {
|
|
423
425
|
streamChannels.delete(id); activeRequests.delete(id);
|
|
424
|
-
|
|
426
|
+
// A streamed terminal frame can precede asynchronous cache writes. Only
|
|
427
|
+
// this acknowledgement confirms that all request work has actually ended.
|
|
428
|
+
parentPort.postMessage({ id, type: 'settled' });
|
|
425
429
|
}
|
|
426
430
|
});
|
package/runtime/transport.mjs
CHANGED
|
@@ -2,6 +2,10 @@ export const MAX_STREAM_CHUNK = 64 * 1024;
|
|
|
2
2
|
const MAX_CONTROL_BYTES = 64 * 1024;
|
|
3
3
|
const NO_BODY = new Set([204, 205, 304]);
|
|
4
4
|
|
|
5
|
+
export class ProtocolWriteError extends Error {
|
|
6
|
+
constructor(cause) { super('Request transport write failed', { cause }); }
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
function bytes(value) {
|
|
6
10
|
if (typeof value === 'string') return Buffer.from(value);
|
|
7
11
|
if (value instanceof ArrayBuffer) return Buffer.from(value);
|
|
@@ -9,27 +13,12 @@ function bytes(value) {
|
|
|
9
13
|
throw new TypeError('A response stream must yield bytes');
|
|
10
14
|
}
|
|
11
15
|
|
|
12
|
-
async function* responseChunks(body) {
|
|
13
|
-
if (body === null || body === undefined) return;
|
|
14
|
-
if (typeof body === 'string' || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
15
|
-
const value = bytes(body);
|
|
16
|
-
for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) yield value.subarray(offset, offset + MAX_STREAM_CHUNK);
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
if (typeof body[Symbol.asyncIterator] !== 'function' && typeof body[Symbol.iterator] !== 'function') {
|
|
20
|
-
throw new TypeError('Invalid response stream');
|
|
21
|
-
}
|
|
22
|
-
for await (const item of body) {
|
|
23
|
-
const value = bytes(item);
|
|
24
|
-
for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) yield value.subarray(offset, offset + MAX_STREAM_CHUNK);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
// A resolved write means the pipe has accepted the bytes. Awaiting every frame
|
|
29
17
|
// prevents Node's writable queue from growing with a slow Rust/HTTP consumer.
|
|
30
18
|
export function createProtocolOutput(write, { cork, uncork } = {}) {
|
|
31
19
|
const send = value => new Promise((resolve, reject) => {
|
|
32
|
-
write(value, error => error ? reject(error) : resolve());
|
|
20
|
+
try { write(value, error => error ? reject(new ProtocolWriteError(error)) : resolve()); }
|
|
21
|
+
catch (error) { reject(new ProtocolWriteError(error)); }
|
|
33
22
|
});
|
|
34
23
|
const controlLine = frame => {
|
|
35
24
|
const value = JSON.stringify(frame) + '\n';
|
|
@@ -42,11 +31,11 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
|
|
|
42
31
|
try {
|
|
43
32
|
for (let index = 0; index < parts.length; index++) {
|
|
44
33
|
write(parts[index], error => {
|
|
45
|
-
if (error) reject(error);
|
|
34
|
+
if (error) reject(new ProtocolWriteError(error));
|
|
46
35
|
else if (index === parts.length - 1) resolve();
|
|
47
36
|
});
|
|
48
37
|
}
|
|
49
|
-
} catch (error) { reject(error); }
|
|
38
|
+
} catch (error) { reject(new ProtocolWriteError(error)); }
|
|
50
39
|
finally { uncork(); }
|
|
51
40
|
});
|
|
52
41
|
const sendChunk = (id, chunk) => {
|
|
@@ -57,48 +46,79 @@ export function createProtocolOutput(write, { cork, uncork } = {}) {
|
|
|
57
46
|
};
|
|
58
47
|
|
|
59
48
|
return async function output(id, result, { stream = false, compactResponse = false, method = 'GET', production = true, signalController } = {}) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
const headers = Object.fromEntries(Object.entries(result.headers || {}).filter(([name]) => name.toLowerCase() !== 'content-length'));
|
|
74
|
-
if (compactResponse && !result.isr && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
|
|
75
|
-
const body = bytes(responseBody);
|
|
76
|
-
if (body.length <= 16 * 1024) {
|
|
77
|
-
const complete = controlLine({ id, type: 'complete', status: result.status, headers, length: body.length, ...pageError });
|
|
78
|
-
if (cork && uncork) await sendParts(body.length ? [complete, body] : [complete]);
|
|
79
|
-
else { await send(complete); if (body.length) await send(body); }
|
|
49
|
+
let responseStarted = false, bodyCancelled = false;
|
|
50
|
+
try {
|
|
51
|
+
signalController?.signal.throwIfAborted();
|
|
52
|
+
const responseBody = result.bufferedBody ?? result.body;
|
|
53
|
+
const pageError = { ...(result.pageError ? { pageError: result.pageError } : {}),
|
|
54
|
+
...(result.pageFailure ? { pageFailure: result.pageFailure } : {}) };
|
|
55
|
+
if (stream && !result.isr && (method === 'HEAD' || NO_BODY.has(result.status))) {
|
|
56
|
+
bodyCancelled = true;
|
|
57
|
+
await result.cancel?.();
|
|
58
|
+
if (result.body?.cancel && !result.body.locked) await result.body.cancel();
|
|
59
|
+
await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: '', ...pageError }) + '\n');
|
|
80
60
|
return;
|
|
81
61
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (cork && uncork && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
|
|
85
|
-
const body = bytes(responseBody);
|
|
86
|
-
if (body.length <= MAX_STREAM_CHUNK) {
|
|
87
|
-
// Pages HTML and buffered action responses already know their entire
|
|
88
|
-
// small body. Send their frames together, keeping the same wire format.
|
|
89
|
-
await sendParts([head, ...(body.length ? [controlLine({id,type:'chunk',length:body.length}),body] : []), controlLine({id,type:'end'})]);
|
|
62
|
+
if (!stream && !result.isr) {
|
|
63
|
+
await send(JSON.stringify({ id, status: result.status, headers: result.headers || {}, body: Buffer.from(result.body || '').toString('base64'), ...pageError }) + '\n');
|
|
90
64
|
return;
|
|
91
65
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
66
|
+
const headers = Object.fromEntries(Object.entries(result.headers || {}).filter(([name]) => name.toLowerCase() !== 'content-length'));
|
|
67
|
+
if (compactResponse && !result.isr && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
|
|
68
|
+
const body = bytes(responseBody);
|
|
69
|
+
if (body.length <= 16 * 1024) {
|
|
70
|
+
const complete = controlLine({ id, type: 'complete', status: result.status, headers, length: body.length, ...pageError });
|
|
71
|
+
if (cork && uncork) await sendParts(body.length ? [complete, body] : [complete]);
|
|
72
|
+
else { await send(complete); if (body.length) await send(body); }
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const head = controlLine({ id, type: 'head', status: result.status, headers, ...pageError, ...(result.isr ? { isr: result.isr } : {}) });
|
|
77
|
+
if (cork && uncork && (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody))) {
|
|
78
|
+
const body = bytes(responseBody);
|
|
79
|
+
if (body.length <= MAX_STREAM_CHUNK) {
|
|
80
|
+
// Pages HTML and buffered action responses already know their entire
|
|
81
|
+
// small body. Send their frames together, keeping the same wire format.
|
|
82
|
+
await sendParts([head, ...(body.length ? [controlLine({id,type:'chunk',length:body.length}),body] : []), controlLine({id,type:'end'})]);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await send(head);
|
|
87
|
+
responseStarted = true;
|
|
88
|
+
// Iterate the producer directly: an intermediate async generator adds
|
|
89
|
+
// promise reactions and Buffer views for every already bounded chunk.
|
|
90
|
+
// Each write still settles before the next chunk or producer read.
|
|
91
|
+
if (responseBody !== null && responseBody !== undefined) {
|
|
92
|
+
if (typeof responseBody === 'string' || responseBody instanceof ArrayBuffer || ArrayBuffer.isView(responseBody)) {
|
|
93
|
+
const value = bytes(responseBody);
|
|
94
|
+
for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
|
|
95
|
+
await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
if (typeof responseBody[Symbol.asyncIterator] !== 'function' && typeof responseBody[Symbol.iterator] !== 'function') {
|
|
99
|
+
throw new TypeError('Invalid response stream');
|
|
100
|
+
}
|
|
101
|
+
for await (const item of responseBody) {
|
|
102
|
+
const value = bytes(item);
|
|
103
|
+
if (value.length <= MAX_STREAM_CHUNK) {
|
|
104
|
+
if (value.length) await sendChunk(id, value);
|
|
105
|
+
} else {
|
|
106
|
+
for (let offset = 0; offset < value.length; offset += MAX_STREAM_CHUNK) {
|
|
107
|
+
await sendChunk(id, value.subarray(offset, offset + MAX_STREAM_CHUNK));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
97
112
|
}
|
|
98
113
|
await control({ id, type: 'end' });
|
|
99
114
|
} catch (error) {
|
|
115
|
+
const cancelled = signalController?.signal.aborted || error instanceof ProtocolWriteError;
|
|
100
116
|
signalController?.abort(error);
|
|
101
|
-
await result.cancel?.(error);
|
|
117
|
+
if (!bodyCancelled) await result.cancel?.(error);
|
|
118
|
+
// Socket cancellation has no writable peer and no protocol recovery on
|
|
119
|
+
// this lane. Cancel its producer once; never attempt a second write (and
|
|
120
|
+
// secondary EPIPE) or report a client disconnect as an application error.
|
|
121
|
+
if (cancelled || !responseStarted) throw error;
|
|
102
122
|
console.error('[prnext] Response stream failed:', error?.stack || error);
|
|
103
123
|
// Headers are already on the wire: terminate this response, never emit a
|
|
104
124
|
// replacement HTTP response or retry application code after a mutation.
|
package/runtime/worker.mjs
CHANGED
|
@@ -12,8 +12,11 @@ import net from 'node:net';
|
|
|
12
12
|
import { timingSafeEqual } from 'node:crypto';
|
|
13
13
|
import { Console } from 'node:console';
|
|
14
14
|
import { navigationResponse } from './navigation.mjs';
|
|
15
|
-
import { createProtocolOutput } from './transport.mjs';
|
|
15
|
+
import { createProtocolOutput, ProtocolWriteError } from './transport.mjs';
|
|
16
16
|
import { loadEnvConfig } from './env.mjs';
|
|
17
|
+
import diagnostics from '../compat/diagnostics.cjs';
|
|
18
|
+
|
|
19
|
+
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'node-entry', uptimeMs: process.uptime() * 1000, bootstrapMs: performance.nodeTiming.bootstrapComplete });
|
|
17
20
|
|
|
18
21
|
// stdout is exclusively the transport, including when user packages log during import.
|
|
19
22
|
const writeProtocol = process.stdout.write.bind(process.stdout);
|
|
@@ -34,7 +37,12 @@ process.env.NODE_ENV = manifest.dev ? 'development' : 'production';
|
|
|
34
37
|
const production = !manifest.dev;
|
|
35
38
|
// Load each renderer beside the application only when that request needs it.
|
|
36
39
|
// Middleware and API workers do not pay for React or the Pages/App renderers.
|
|
37
|
-
const runtimeModule = name =>
|
|
40
|
+
const runtimeModule = async name => {
|
|
41
|
+
const started = diagnostics.enabled ? performance.now() : 0;
|
|
42
|
+
const module = await import(pathToFileURL(path.join(distDir, `runtime/${name}.mjs`)).href);
|
|
43
|
+
if (diagnostics.enabled) diagnostics.record('wake', { phase: 'module', module: name, durationMs: performance.now() - started });
|
|
44
|
+
return module;
|
|
45
|
+
};
|
|
38
46
|
let errorResponse;
|
|
39
47
|
try { ({ errorResponse } = await runtimeModule('http')); }
|
|
40
48
|
catch (error) { console.error('PRNext worker could not load the project runtime:', error.message); process.exit(1); }
|
|
@@ -48,7 +56,7 @@ let pagesRuntime;
|
|
|
48
56
|
let apiRuntime;
|
|
49
57
|
let incrementalRuntime;
|
|
50
58
|
|
|
51
|
-
async function handleRequest(input, output, signalController) {
|
|
59
|
+
async function handleRequest(input, output, signalController, responseComplete) {
|
|
52
60
|
let responseStarted = false;
|
|
53
61
|
try {
|
|
54
62
|
if (input.renderMode === 'incremental-cache' && input.routeId === '__prnext_incremental_cache') {
|
|
@@ -78,7 +86,7 @@ async function handleRequest(input, output, signalController) {
|
|
|
78
86
|
appStaticRuntime ??= await import(pathToFileURL(path.join(distDir, 'runtime/app-static.mjs')).href);
|
|
79
87
|
result = await appStaticRuntime.renderAppIsrPage(options);
|
|
80
88
|
} else {
|
|
81
|
-
appRuntime ??= await
|
|
89
|
+
appRuntime ??= await runtimeModule('app-render');
|
|
82
90
|
result = await appRuntime.renderAppPage(options);
|
|
83
91
|
}
|
|
84
92
|
} else if (route.router === 'app' && route.kind === 'api' && input.renderMode === 'isr') {
|
|
@@ -97,16 +105,23 @@ async function handleRequest(input, output, signalController) {
|
|
|
97
105
|
input.body = undefined;
|
|
98
106
|
options.body = undefined;
|
|
99
107
|
responseStarted = true;
|
|
100
|
-
try {
|
|
108
|
+
try {
|
|
109
|
+
await output(input.id, result, { ...input, production, signalController });
|
|
110
|
+
responseComplete?.();
|
|
111
|
+
}
|
|
101
112
|
finally { await result.finalizeCache?.(); }
|
|
102
113
|
} catch (error) {
|
|
114
|
+
const cancelled = signalController.signal.aborted || error instanceof ProtocolWriteError;
|
|
103
115
|
signalController.abort(error);
|
|
104
|
-
if (responseStarted) {
|
|
116
|
+
if (responseStarted || cancelled) {
|
|
105
117
|
// A broken pipe or invalid transport cannot be repaired with a second
|
|
106
|
-
// response on the same request. Rust
|
|
118
|
+
// response on the same request. Rust discards the incomplete lane.
|
|
107
119
|
throw error;
|
|
108
120
|
}
|
|
109
|
-
|
|
121
|
+
// The application's scope was aborted above, but the lane is still valid
|
|
122
|
+
// for its one error/redirect response. Do not mistake that cleanup signal
|
|
123
|
+
// for a client disconnect while writing the replacement headers.
|
|
124
|
+
await output(input?.id ?? null, navigationResponse(error) || errorResponse(error, production), { ...input, production });
|
|
110
125
|
}
|
|
111
126
|
}
|
|
112
127
|
|
|
@@ -114,12 +129,22 @@ async function handleRequest(input, output, signalController) {
|
|
|
114
129
|
// response never blocks another response's bytes or aborts its request context.
|
|
115
130
|
const laneLimit = Math.min(512, Math.max(1, Number(process.env.PRNEXT_WORKER_SOCKET) || 1));
|
|
116
131
|
const token = process.env.PRNEXT_WORKER_TOKEN;
|
|
132
|
+
const reportActivity = process.env.PRNEXT_WORKER_ACTIVITY === '1';
|
|
117
133
|
delete process.env.PRNEXT_WORKER_TOKEN;
|
|
118
134
|
delete process.env.PRNEXT_WORKER_SOCKET;
|
|
135
|
+
delete process.env.PRNEXT_WORKER_ACTIVITY;
|
|
119
136
|
if (token && laneLimit > 1) {
|
|
120
137
|
const expected = Buffer.from(token);
|
|
121
138
|
const sockets = new Set();
|
|
122
139
|
const invocations = new Set();
|
|
140
|
+
let shuttingDown = false;
|
|
141
|
+
let reportedBusy = false;
|
|
142
|
+
const announceActivity = () => {
|
|
143
|
+
if (!reportActivity || !invocations.size || reportedBusy || shuttingDown) return;
|
|
144
|
+
reportedBusy = true;
|
|
145
|
+
writeProtocol('busy\n');
|
|
146
|
+
};
|
|
147
|
+
const exitWhenDrained = () => { if (shuttingDown && !invocations.size) process.exit(0); };
|
|
123
148
|
const server = net.createServer(socket => {
|
|
124
149
|
if (sockets.size >= laneLimit) { socket.destroy(); return; }
|
|
125
150
|
sockets.add(socket); socket.setNoDelay(true);
|
|
@@ -134,7 +159,6 @@ if (token && laneLimit > 1) {
|
|
|
134
159
|
const abandoned = controller;
|
|
135
160
|
if (!abandoned || abandoned.signal.aborted) return;
|
|
136
161
|
abandoned.abort(new Error('Request transport closed'));
|
|
137
|
-
|
|
138
162
|
};
|
|
139
163
|
socket.on('end', disconnected);
|
|
140
164
|
socket.on('close', disconnected);
|
|
@@ -149,42 +173,83 @@ if (token && laneLimit > 1) {
|
|
|
149
173
|
if (invocations.size >= laneLimit) throw new Error('Worker invocation capacity exceeded');
|
|
150
174
|
controller = new AbortController();
|
|
151
175
|
const active = controller;
|
|
152
|
-
let pendingWork = 0, finished = false;
|
|
176
|
+
let pendingWork = 0, unsupervisedWork = 0, finished = false;
|
|
153
177
|
invocations.add(active);
|
|
154
|
-
|
|
178
|
+
announceActivity();
|
|
179
|
+
active.signal[requestWork] = (promise, options) => {
|
|
155
180
|
pendingWork++;
|
|
156
|
-
|
|
181
|
+
// RSC owns both its foreground deadline and its post-response
|
|
182
|
+
// responsiveness watchdog. Do not apply legacy orphan timeouts to
|
|
183
|
+
// legitimate cache work merely because its HTTP body has ended.
|
|
184
|
+
const supervised = options?.supervised === true;
|
|
185
|
+
if (!supervised) unsupervisedWork++;
|
|
186
|
+
const settled = () => {
|
|
187
|
+
pendingWork--;
|
|
188
|
+
if (!supervised) unsupervisedWork--;
|
|
189
|
+
if (pendingWork && !unsupervisedWork) clearTimeout(workTimer);
|
|
190
|
+
if (!pendingWork && finished) {
|
|
191
|
+
clearTimeout(workTimer);
|
|
192
|
+
invocations.delete(active); exitWhenDrained();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
157
195
|
workScope.runInAsyncScope(() => Promise.resolve(promise).then(settled, settled));
|
|
158
196
|
};
|
|
197
|
+
let workTimer;
|
|
159
198
|
const retireIfAbandoned = () => {
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
if (pendingWork && !unsupervisedWork) return;
|
|
200
|
+
workTimer = setTimeout(() => {
|
|
201
|
+
if (invocations.has(active) && (!pendingWork || unsupervisedWork)) process.exit(70);
|
|
202
|
+
}, 5000);
|
|
203
|
+
workTimer.unref();
|
|
162
204
|
};
|
|
163
205
|
active.signal.addEventListener('abort', retireIfAbandoned, { once: true });
|
|
164
|
-
try { await handleRequest(input, output, active); }
|
|
206
|
+
try { await handleRequest(input, output, active, () => { controller = undefined; }); }
|
|
165
207
|
finally {
|
|
166
208
|
finished = true;
|
|
167
|
-
if (!pendingWork) invocations.delete(active);
|
|
168
|
-
else
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
209
|
+
if (!pendingWork) { invocations.delete(active); clearTimeout(workTimer); }
|
|
210
|
+
else {
|
|
211
|
+
if (unsupervisedWork && !active.signal.aborted) {
|
|
212
|
+
workTimer = setTimeout(() => { if (invocations.has(active) && unsupervisedWork) process.exit(70); }, 30000);
|
|
213
|
+
workTimer.unref();
|
|
214
|
+
}
|
|
173
215
|
}
|
|
174
216
|
controller = undefined;
|
|
217
|
+
exitWhenDrained();
|
|
175
218
|
}
|
|
176
219
|
}
|
|
177
220
|
} catch (error) {
|
|
178
|
-
if (!socket.destroyed) console.error('[prnext] Request lane failed:', error?.message || error);
|
|
221
|
+
if (!socket.destroyed && !socket.readableEnded && !(error instanceof ProtocolWriteError)) console.error('[prnext] Request lane failed:', error?.message || error);
|
|
179
222
|
} finally { socket.destroy(); }
|
|
180
223
|
})();
|
|
181
224
|
});
|
|
182
225
|
// The stdin pipe is a parent-lifetime guard even though requests use sockets.
|
|
226
|
+
let heartbeat;
|
|
227
|
+
const shutdown = () => {
|
|
228
|
+
if (shuttingDown) return;
|
|
229
|
+
shuttingDown = true;
|
|
230
|
+
clearInterval(heartbeat);
|
|
231
|
+
for (const socket of sockets) socket.destroy();
|
|
232
|
+
server.close();
|
|
233
|
+
exitWhenDrained();
|
|
234
|
+
};
|
|
183
235
|
process.stdin.resume();
|
|
184
|
-
process.stdin.once('end',
|
|
236
|
+
process.stdin.once('end', shutdown);
|
|
237
|
+
// The native pool can close its control reader before Node observes stdin
|
|
238
|
+
// EOF. Treat that broken parent pipe as the same graceful shutdown; ordinary
|
|
239
|
+
// stdout failures remain fatal instead of silently losing protocol errors.
|
|
240
|
+
process.stdout.on('error', error => {
|
|
241
|
+
if (error.code === 'EPIPE') shutdown();
|
|
242
|
+
else throw error;
|
|
243
|
+
});
|
|
185
244
|
server.listen(0, '127.0.0.1', () => {
|
|
186
|
-
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true }) + '\n');
|
|
187
|
-
setInterval(() =>
|
|
245
|
+
writeProtocol(JSON.stringify({ port: server.address().port, binary: true, heartbeat: true, activity: reportActivity, gracefulShutdown: true }) + '\n');
|
|
246
|
+
heartbeat = setInterval(() => {
|
|
247
|
+
// Coalesce the idle transition with the existing heartbeat. Normal
|
|
248
|
+
// request completion adds no stdout messages, and short cache bursts
|
|
249
|
+
// cannot add two messages for every response.
|
|
250
|
+
reportedBusy = reportActivity && invocations.size > 0;
|
|
251
|
+
writeProtocol(reportedBusy ? 'busy\n' : 'alive\n');
|
|
252
|
+
}, 1000).unref();
|
|
188
253
|
});
|
|
189
254
|
} else {
|
|
190
255
|
const output = createProtocolOutput(writeProtocol, { cork: () => process.stdout.cork(), uncork: () => process.stdout.uncork() });
|