@lark-apaas/fullstack-cli 1.1.65 → 1.1.66-alpha.20260902183957
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/dist/index.js +2625 -49
- package/package.json +4 -1
- package/templates/scripts/dev.js +713 -54
- package/templates/scripts/server-handoff-runtime.mjs +1912 -0
package/templates/scripts/dev.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const net = require('net');
|
|
5
7
|
const path = require('path');
|
|
6
8
|
const { spawn, execSync } = require('child_process');
|
|
7
9
|
const readline = require('readline');
|
|
@@ -31,13 +33,50 @@ loadEnv();
|
|
|
31
33
|
|
|
32
34
|
// ── Configuration ─────────────────────────────────────────────────────────────
|
|
33
35
|
const LOG_DIR = process.env.LOG_DIR || 'logs';
|
|
34
|
-
const MAX_RESTART_COUNT =
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const MAX_RESTART_COUNT =
|
|
37
|
+
process.env.MAX_RESTART_COUNT != null && process.env.MAX_RESTART_COUNT !== ''
|
|
38
|
+
? parseInt(process.env.MAX_RESTART_COUNT, 10)
|
|
39
|
+
: Infinity;
|
|
37
40
|
const RESTART_DELAY = parseInt(process.env.RESTART_DELAY, 10) || 2;
|
|
38
41
|
const MAX_DELAY = 8;
|
|
39
|
-
const
|
|
42
|
+
const LEGACY_SERVER_PORT = process.env.SERVER_PORT || '3000';
|
|
43
|
+
const PLATFORM_HANDOFF_PUBLIC_PORT = 3000;
|
|
44
|
+
const MAX_HANDOFF_READINESS_RESPONSE_BYTES = 64 * 1024;
|
|
45
|
+
const CLIENT_BASE_PATH =
|
|
46
|
+
process.env.CLIENT_BASE_PATH?.startsWith('/') &&
|
|
47
|
+
!process.env.CLIENT_BASE_PATH.startsWith('//')
|
|
48
|
+
? process.env.CLIENT_BASE_PATH.replace(/\/+$/, '')
|
|
49
|
+
: '';
|
|
50
|
+
const HANDOFF_READINESS_PATHS = [
|
|
51
|
+
'/__innerapi__/capability/list',
|
|
52
|
+
...(CLIENT_BASE_PATH
|
|
53
|
+
? [`${CLIENT_BASE_PATH}/__innerapi__/capability/list`]
|
|
54
|
+
: []),
|
|
55
|
+
];
|
|
40
56
|
const CLIENT_DEV_PORT = process.env.CLIENT_DEV_PORT || '8080';
|
|
57
|
+
// This is an ownership receipt, not the raw TCC flag. The sandbox entrypoint
|
|
58
|
+
// sets it only after the handoff runtime has bound the public port and
|
|
59
|
+
// published its owner marker. If that handshake fails, the legacy path below
|
|
60
|
+
// remains the sole backend owner.
|
|
61
|
+
const BACKEND_OWNED_BY_HANDOFF =
|
|
62
|
+
process.env.MIAODA_NEST_BACKEND_OWNER === 'handoff';
|
|
63
|
+
const HANDOFF_CONTROL_ROOT = '/tmp/miaoda-cold-start';
|
|
64
|
+
const HANDOFF_DECISION_FILE = path.join(HANDOFF_CONTROL_ROOT, 'decision.json');
|
|
65
|
+
const HANDOFF_OWNER_FILE = path.join(
|
|
66
|
+
HANDOFF_CONTROL_ROOT,
|
|
67
|
+
'runtime-owner.json'
|
|
68
|
+
);
|
|
69
|
+
const HANDOFF_PROCESS_REGISTRY_FILE = path.join(
|
|
70
|
+
HANDOFF_CONTROL_ROOT,
|
|
71
|
+
'runtime-processes.json'
|
|
72
|
+
);
|
|
73
|
+
const HANDOFF_DECISION = BACKEND_OWNED_BY_HANDOFF
|
|
74
|
+
? readStrictColdStartDecision()
|
|
75
|
+
: null;
|
|
76
|
+
const HANDOFF_BOOT_ID = HANDOFF_DECISION?.bootId || '';
|
|
77
|
+
const HANDOFF_RESTORE_EPOCH_MS = HANDOFF_DECISION?.restoreEpochMs || 0;
|
|
78
|
+
const PUBLIC_SERVER_PORT =
|
|
79
|
+
HANDOFF_DECISION?.publicPort || Number(LEGACY_SERVER_PORT);
|
|
41
80
|
|
|
42
81
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
43
82
|
|
|
@@ -50,11 +89,16 @@ const devLogFd = fs.openSync(devLogPath, 'a');
|
|
|
50
89
|
function timestamp() {
|
|
51
90
|
const now = new Date();
|
|
52
91
|
return (
|
|
53
|
-
now.getFullYear() +
|
|
54
|
-
|
|
55
|
-
String(now.
|
|
56
|
-
|
|
57
|
-
String(now.
|
|
92
|
+
now.getFullYear() +
|
|
93
|
+
'-' +
|
|
94
|
+
String(now.getMonth() + 1).padStart(2, '0') +
|
|
95
|
+
'-' +
|
|
96
|
+
String(now.getDate()).padStart(2, '0') +
|
|
97
|
+
' ' +
|
|
98
|
+
String(now.getHours()).padStart(2, '0') +
|
|
99
|
+
':' +
|
|
100
|
+
String(now.getMinutes()).padStart(2, '0') +
|
|
101
|
+
':' +
|
|
58
102
|
String(now.getSeconds()).padStart(2, '0')
|
|
59
103
|
);
|
|
60
104
|
}
|
|
@@ -63,26 +107,55 @@ function timestamp() {
|
|
|
63
107
|
// Each pending fs.write retains ~1.5 KB (msg + libuv/V8 wrappers); 1000 ≈ ~1.5 MB ceiling.
|
|
64
108
|
let _stdoutInFlight = 0;
|
|
65
109
|
const STDOUT_MAX_INFLIGHT = 1000;
|
|
110
|
+
const reportedBestEffortFailures = new Set();
|
|
111
|
+
|
|
112
|
+
function reportBestEffortFailure(operation, error) {
|
|
113
|
+
if (reportedBestEffortFailures.has(operation)) return;
|
|
114
|
+
reportedBestEffortFailures.add(operation);
|
|
115
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
116
|
+
try {
|
|
117
|
+
fs.write(2, `[dev.js] ${operation}: ${detail}\n`, () => {});
|
|
118
|
+
} catch {
|
|
119
|
+
// fd 2 is the last-resort diagnostic sink; there is no safe recursive
|
|
120
|
+
// fallback if submitting this best-effort write itself fails.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
66
123
|
|
|
67
124
|
/** Write to dev.std.log (sync, guaranteed) + mirror to terminal (async, non-blocking) */
|
|
68
125
|
function writeOutput(msg) {
|
|
69
126
|
// File first and synchronously — read-logs reads this file; it must never be gated
|
|
70
127
|
// by the terminal consumer.
|
|
71
|
-
try {
|
|
128
|
+
try {
|
|
129
|
+
fs.writeSync(devStdLogFd, msg);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
reportBestEffortFailure('write dev.std.log failed', error);
|
|
132
|
+
}
|
|
72
133
|
// stdout mirror via async fs.write: if process.stdout is a pty/pipe whose consumer
|
|
73
134
|
// stalls, the block happens on the libuv threadpool, NOT the event loop — so the
|
|
74
135
|
// synchronous log-FILE writes above (and subsequent readline callbacks) keep running.
|
|
75
136
|
// Drop overflow when too many writes are already pending (best-effort mirror).
|
|
76
|
-
if (_stdoutInFlight >= STDOUT_MAX_INFLIGHT) {
|
|
137
|
+
if (_stdoutInFlight >= STDOUT_MAX_INFLIGHT) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
77
140
|
_stdoutInFlight++;
|
|
78
|
-
try {
|
|
141
|
+
try {
|
|
142
|
+
fs.write(1, msg, () => {
|
|
143
|
+
_stdoutInFlight--;
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
_stdoutInFlight--;
|
|
147
|
+
}
|
|
79
148
|
}
|
|
80
149
|
|
|
81
150
|
/** Structured event log → terminal + dev.std.log + dev.log */
|
|
82
151
|
function logEvent(level, name, message) {
|
|
83
152
|
const msg = `[${timestamp()}] [${level}] [${name}] ${message}\n`;
|
|
84
153
|
writeOutput(msg);
|
|
85
|
-
try {
|
|
154
|
+
try {
|
|
155
|
+
fs.writeSync(devLogFd, msg);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
reportBestEffortFailure('write dev.log failed', error);
|
|
158
|
+
}
|
|
86
159
|
}
|
|
87
160
|
|
|
88
161
|
// ── Process group management ──────────────────────────────────────────────────
|
|
@@ -94,11 +167,20 @@ function killProcessGroup(pid, signal) {
|
|
|
94
167
|
|
|
95
168
|
function killOrphansByPort(port) {
|
|
96
169
|
try {
|
|
97
|
-
const pids = execSync(`lsof -ti :${port}`, {
|
|
170
|
+
const pids = execSync(`lsof -ti :${port}`, {
|
|
171
|
+
encoding: 'utf8',
|
|
172
|
+
timeout: 5000,
|
|
173
|
+
}).trim();
|
|
98
174
|
if (pids) {
|
|
99
175
|
const pidList = pids.split('\n').filter(Boolean);
|
|
100
176
|
for (const p of pidList) {
|
|
101
|
-
try {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(parseInt(p, 10), 'SIGKILL');
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error?.code !== 'ESRCH') {
|
|
181
|
+
reportBestEffortFailure('kill legacy port owner failed', error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
102
184
|
}
|
|
103
185
|
return pidList;
|
|
104
186
|
}
|
|
@@ -111,18 +193,442 @@ let stopping = false;
|
|
|
111
193
|
const managedProcesses = []; // { name, pid, child }
|
|
112
194
|
|
|
113
195
|
function sleep(ms) {
|
|
114
|
-
return new Promise(
|
|
196
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hasExactKeys(value, expected) {
|
|
200
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
201
|
+
const actual = Object.keys(value).sort();
|
|
202
|
+
const sortedExpected = [...expected].sort();
|
|
203
|
+
return (
|
|
204
|
+
actual.length === sortedExpected.length &&
|
|
205
|
+
actual.every((key, index) => key === sortedExpected[index])
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readLinuxProcessIdentity(pid) {
|
|
210
|
+
if (process.platform !== 'linux' || !Number.isSafeInteger(pid) || pid <= 0)
|
|
211
|
+
return null;
|
|
212
|
+
try {
|
|
213
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
214
|
+
const commandEnd = stat.lastIndexOf(')');
|
|
215
|
+
if (commandEnd < 0) return null;
|
|
216
|
+
const fields = stat
|
|
217
|
+
.slice(commandEnd + 1)
|
|
218
|
+
.trim()
|
|
219
|
+
.split(/\s+/);
|
|
220
|
+
if (!/^\d+$/.test(fields[19] || '') || !/^\d+$/.test(fields[2] || ''))
|
|
221
|
+
return null;
|
|
222
|
+
return {
|
|
223
|
+
state: fields[0],
|
|
224
|
+
processStartTicks: fields[19],
|
|
225
|
+
processGroupId: Number(fields[2]),
|
|
226
|
+
};
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function processIdentityIsLive(pid, expectedStartTicks) {
|
|
233
|
+
if (process.platform === 'linux') {
|
|
234
|
+
const identity = readLinuxProcessIdentity(pid);
|
|
235
|
+
return Boolean(
|
|
236
|
+
identity &&
|
|
237
|
+
identity.state !== 'Z' &&
|
|
238
|
+
identity.processStartTicks === expectedStartTicks
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
process.kill(pid, 0);
|
|
243
|
+
return true;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readJsonNoFollow(file) {
|
|
250
|
+
try {
|
|
251
|
+
const parent = fs.lstatSync(path.dirname(file));
|
|
252
|
+
if (
|
|
253
|
+
parent.isSymbolicLink() ||
|
|
254
|
+
!parent.isDirectory() ||
|
|
255
|
+
(parent.mode & 0o777) !== 0o700 ||
|
|
256
|
+
(typeof process.getuid === 'function' && parent.uid !== process.getuid())
|
|
257
|
+
)
|
|
258
|
+
return null;
|
|
259
|
+
const before = fs.lstatSync(file);
|
|
260
|
+
if (
|
|
261
|
+
before.isSymbolicLink() ||
|
|
262
|
+
!before.isFile() ||
|
|
263
|
+
(before.mode & 0o777) !== 0o600 ||
|
|
264
|
+
(typeof process.getuid === 'function' && before.uid !== process.getuid())
|
|
265
|
+
)
|
|
266
|
+
return null;
|
|
267
|
+
const fd = fs.openSync(
|
|
268
|
+
file,
|
|
269
|
+
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
|
|
270
|
+
);
|
|
271
|
+
try {
|
|
272
|
+
const opened = fs.fstatSync(fd);
|
|
273
|
+
if (
|
|
274
|
+
!opened.isFile() ||
|
|
275
|
+
before.dev !== opened.dev ||
|
|
276
|
+
before.ino !== opened.ino
|
|
277
|
+
)
|
|
278
|
+
return null;
|
|
279
|
+
return { payload: JSON.parse(fs.readFileSync(fd, 'utf8')), stat: opened };
|
|
280
|
+
} finally {
|
|
281
|
+
fs.closeSync(fd);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function readStrictColdStartDecision() {
|
|
289
|
+
const decision = readJsonNoFollow(HANDOFF_DECISION_FILE)?.payload;
|
|
290
|
+
if (
|
|
291
|
+
!hasExactKeys(decision, [
|
|
292
|
+
'schemaVersion',
|
|
293
|
+
'appId',
|
|
294
|
+
'bootId',
|
|
295
|
+
'restoreEpochMs',
|
|
296
|
+
'publicPort',
|
|
297
|
+
'viteDepsCacheEnabled',
|
|
298
|
+
'nestBundleHandoffEnabled',
|
|
299
|
+
]) ||
|
|
300
|
+
decision.schemaVersion !== 1 ||
|
|
301
|
+
typeof decision.appId !== 'string' ||
|
|
302
|
+
decision.appId.length === 0 ||
|
|
303
|
+
typeof decision.bootId !== 'string' ||
|
|
304
|
+
!/^[a-zA-Z0-9._:-]{1,128}$/.test(decision.bootId) ||
|
|
305
|
+
!Number.isSafeInteger(decision.restoreEpochMs) ||
|
|
306
|
+
decision.restoreEpochMs <= 0 ||
|
|
307
|
+
decision.publicPort !== PLATFORM_HANDOFF_PUBLIC_PORT ||
|
|
308
|
+
typeof decision.viteDepsCacheEnabled !== 'boolean' ||
|
|
309
|
+
decision.nestBundleHandoffEnabled !== true
|
|
310
|
+
) {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
return decision;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const OWNER_KEYS = [
|
|
317
|
+
'schemaVersion',
|
|
318
|
+
'bootId',
|
|
319
|
+
'restoreEpochMs',
|
|
320
|
+
'generationId',
|
|
321
|
+
'pid',
|
|
322
|
+
'processStartTicks',
|
|
323
|
+
'publicPort',
|
|
324
|
+
'createdAtMs',
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
function readStrictHandoffOwner() {
|
|
328
|
+
const opened = readJsonNoFollow(HANDOFF_OWNER_FILE);
|
|
329
|
+
const owner = opened?.payload;
|
|
330
|
+
if (
|
|
331
|
+
!hasExactKeys(owner, OWNER_KEYS) ||
|
|
332
|
+
owner.schemaVersion !== 1 ||
|
|
333
|
+
owner.bootId !== HANDOFF_BOOT_ID ||
|
|
334
|
+
owner.restoreEpochMs !== HANDOFF_RESTORE_EPOCH_MS ||
|
|
335
|
+
!/^[a-f0-9]{64}$/.test(owner.generationId || '') ||
|
|
336
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
337
|
+
owner.pid <= 0 ||
|
|
338
|
+
!/^\d+$/.test(owner.processStartTicks || '') ||
|
|
339
|
+
owner.publicPort !== PUBLIC_SERVER_PORT ||
|
|
340
|
+
!Number.isSafeInteger(owner.createdAtMs) ||
|
|
341
|
+
owner.createdAtMs < HANDOFF_RESTORE_EPOCH_MS ||
|
|
342
|
+
!processIdentityIsLive(owner.pid, owner.processStartTicks)
|
|
343
|
+
)
|
|
344
|
+
return null;
|
|
345
|
+
return owner;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function publicServerPathReachable(requestPath) {
|
|
349
|
+
return new Promise(resolve => {
|
|
350
|
+
let settled = false;
|
|
351
|
+
const finish = reachable => {
|
|
352
|
+
if (settled) return;
|
|
353
|
+
settled = true;
|
|
354
|
+
resolve(reachable);
|
|
355
|
+
};
|
|
356
|
+
const request = http.get(
|
|
357
|
+
{
|
|
358
|
+
host: '127.0.0.1',
|
|
359
|
+
port: PUBLIC_SERVER_PORT,
|
|
360
|
+
path: requestPath,
|
|
361
|
+
},
|
|
362
|
+
response => {
|
|
363
|
+
if (response.statusCode !== 200) {
|
|
364
|
+
response.resume();
|
|
365
|
+
response.once('end', () => finish(false));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const chunks = [];
|
|
369
|
+
let responseBytes = 0;
|
|
370
|
+
response.on('data', chunk => {
|
|
371
|
+
if (settled) return;
|
|
372
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
373
|
+
responseBytes += buffer.length;
|
|
374
|
+
if (responseBytes > MAX_HANDOFF_READINESS_RESPONSE_BYTES) {
|
|
375
|
+
response.destroy();
|
|
376
|
+
finish(false);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
chunks.push(buffer);
|
|
380
|
+
});
|
|
381
|
+
response.once('end', () => {
|
|
382
|
+
if (settled) return;
|
|
383
|
+
try {
|
|
384
|
+
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
385
|
+
finish(
|
|
386
|
+
payload?.status_code === '0' &&
|
|
387
|
+
payload.data &&
|
|
388
|
+
typeof payload.data === 'object' &&
|
|
389
|
+
!Array.isArray(payload.data) &&
|
|
390
|
+
Array.isArray(payload.data.capabilities)
|
|
391
|
+
);
|
|
392
|
+
} catch {
|
|
393
|
+
finish(false);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
response.once('error', () => finish(false));
|
|
397
|
+
}
|
|
398
|
+
);
|
|
399
|
+
request.setTimeout(250, () => {
|
|
400
|
+
request.destroy();
|
|
401
|
+
finish(false);
|
|
402
|
+
});
|
|
403
|
+
request.once('error', () => finish(false));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function publicServerReachable() {
|
|
408
|
+
for (const requestPath of HANDOFF_READINESS_PATHS) {
|
|
409
|
+
if (await publicServerPathReachable(requestPath)) return true;
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function registryMatchesOwner(registry, owner) {
|
|
415
|
+
if (
|
|
416
|
+
!hasExactKeys(registry, [
|
|
417
|
+
'schemaVersion',
|
|
418
|
+
'bootId',
|
|
419
|
+
'restoreEpochMs',
|
|
420
|
+
'runtimePid',
|
|
421
|
+
'runtimeProcessStartTicks',
|
|
422
|
+
'updatedAtMs',
|
|
423
|
+
'processes',
|
|
424
|
+
]) ||
|
|
425
|
+
registry.schemaVersion !== 1 ||
|
|
426
|
+
registry.bootId !== HANDOFF_BOOT_ID ||
|
|
427
|
+
registry.restoreEpochMs !== HANDOFF_RESTORE_EPOCH_MS ||
|
|
428
|
+
registry.runtimePid !== owner.pid ||
|
|
429
|
+
registry.runtimeProcessStartTicks !== owner.processStartTicks ||
|
|
430
|
+
!Number.isSafeInteger(registry.updatedAtMs) ||
|
|
431
|
+
registry.updatedAtMs < HANDOFF_RESTORE_EPOCH_MS ||
|
|
432
|
+
!Array.isArray(registry.processes)
|
|
433
|
+
)
|
|
434
|
+
return false;
|
|
435
|
+
const roles = new Set();
|
|
436
|
+
return registry.processes.every(owned => {
|
|
437
|
+
const valid =
|
|
438
|
+
hasExactKeys(owned, ['role', 'pid', 'processStartTicks']) &&
|
|
439
|
+
['cache', 'source', 'action-plugin-init'].includes(owned.role) &&
|
|
440
|
+
!roles.has(owned.role) &&
|
|
441
|
+
Number.isSafeInteger(owned.pid) &&
|
|
442
|
+
owned.pid > 0 &&
|
|
443
|
+
/^\d+$/.test(owned.processStartTicks || '');
|
|
444
|
+
roles.add(owned.role);
|
|
445
|
+
return valid;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function ownedGroupIdentityMatches(owned) {
|
|
450
|
+
const leaderIdentity = readLinuxProcessIdentity(owned.pid);
|
|
451
|
+
if (
|
|
452
|
+
leaderIdentity &&
|
|
453
|
+
(leaderIdentity.processStartTicks !== owned.processStartTicks ||
|
|
454
|
+
leaderIdentity.processGroupId !== owned.pid)
|
|
455
|
+
) {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
// Linux reserves a numeric PGID while any member of that group remains, so
|
|
459
|
+
// the dead leader's PID cannot be reused until the leaderless owned group is
|
|
460
|
+
// empty. Re-check both PID identity and group existence immediately before
|
|
461
|
+
// every signal; if a new leader appeared, fail closed above.
|
|
462
|
+
if (!leaderIdentity) {
|
|
463
|
+
try {
|
|
464
|
+
process.kill(owned.pid, 0);
|
|
465
|
+
return false;
|
|
466
|
+
} catch (error) {
|
|
467
|
+
if (error?.code !== 'ESRCH') return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
for (const name of fs.readdirSync('/proc')) {
|
|
472
|
+
if (!/^\d+$/.test(name)) continue;
|
|
473
|
+
const member = readLinuxProcessIdentity(Number(name));
|
|
474
|
+
if (member?.processGroupId === owned.pid && member.state !== 'Z') {
|
|
475
|
+
const currentLeader = readLinuxProcessIdentity(owned.pid);
|
|
476
|
+
return Boolean(
|
|
477
|
+
!currentLeader ||
|
|
478
|
+
(currentLeader.processStartTicks === owned.processStartTicks &&
|
|
479
|
+
currentLeader.processGroupId === owned.pid)
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} catch {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function runtimeGroupIdentityMatches(owner) {
|
|
490
|
+
const identity = readLinuxProcessIdentity(owner.pid);
|
|
491
|
+
return Boolean(
|
|
492
|
+
identity &&
|
|
493
|
+
identity.state !== 'Z' &&
|
|
494
|
+
identity.processStartTicks === owner.processStartTicks &&
|
|
495
|
+
identity.processGroupId === owner.pid
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function terminateStrictRuntimeOwner(owner) {
|
|
500
|
+
if (process.platform !== 'linux' || !runtimeGroupIdentityMatches(owner))
|
|
501
|
+
return;
|
|
502
|
+
// Re-check pid birth identity and PGID immediately before every signal. A
|
|
503
|
+
// numeric pid from the marker is never sufficient authority after waiting.
|
|
504
|
+
if (runtimeGroupIdentityMatches(owner)) {
|
|
505
|
+
try {
|
|
506
|
+
process.kill(-owner.pid, 'SIGTERM');
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
const deadline = Date.now() + 2000;
|
|
510
|
+
while (Date.now() < deadline && runtimeGroupIdentityMatches(owner)) {
|
|
511
|
+
await sleep(25);
|
|
512
|
+
}
|
|
513
|
+
if (runtimeGroupIdentityMatches(owner)) {
|
|
514
|
+
try {
|
|
515
|
+
process.kill(-owner.pid, 'SIGKILL');
|
|
516
|
+
} catch {}
|
|
517
|
+
}
|
|
518
|
+
const killDeadline = Date.now() + 1000;
|
|
519
|
+
while (Date.now() < killDeadline && runtimeGroupIdentityMatches(owner)) {
|
|
520
|
+
await sleep(25);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function cleanupDeadRuntimeRegistry(owner) {
|
|
525
|
+
if (process.platform !== 'linux') return;
|
|
526
|
+
const opened = readJsonNoFollow(HANDOFF_PROCESS_REGISTRY_FILE);
|
|
527
|
+
const registry = opened?.payload;
|
|
528
|
+
if (!opened || !registryMatchesOwner(registry, owner)) return;
|
|
529
|
+
for (const owned of registry.processes) {
|
|
530
|
+
if (!ownedGroupIdentityMatches(owned)) continue;
|
|
531
|
+
try {
|
|
532
|
+
process.kill(-owned.pid, 'SIGTERM');
|
|
533
|
+
} catch {}
|
|
534
|
+
}
|
|
535
|
+
const deadline = Date.now() + 2000;
|
|
536
|
+
while (
|
|
537
|
+
Date.now() < deadline &&
|
|
538
|
+
registry.processes.some(ownedGroupIdentityMatches)
|
|
539
|
+
) {
|
|
540
|
+
await sleep(25);
|
|
541
|
+
}
|
|
542
|
+
for (const owned of registry.processes) {
|
|
543
|
+
if (!ownedGroupIdentityMatches(owned)) continue;
|
|
544
|
+
try {
|
|
545
|
+
process.kill(-owned.pid, 'SIGKILL');
|
|
546
|
+
} catch {}
|
|
547
|
+
}
|
|
548
|
+
const killDeadline = Date.now() + 1000;
|
|
549
|
+
while (
|
|
550
|
+
Date.now() < killDeadline &&
|
|
551
|
+
registry.processes.some(ownedGroupIdentityMatches)
|
|
552
|
+
) {
|
|
553
|
+
await sleep(25);
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const after = fs.lstatSync(HANDOFF_PROCESS_REGISTRY_FILE);
|
|
557
|
+
if (
|
|
558
|
+
after.dev === opened.stat.dev &&
|
|
559
|
+
after.ino === opened.stat.ino &&
|
|
560
|
+
!registry.processes.some(ownedGroupIdentityMatches)
|
|
561
|
+
) {
|
|
562
|
+
fs.unlinkSync(HANDOFF_PROCESS_REGISTRY_FILE);
|
|
563
|
+
}
|
|
564
|
+
} catch {}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function removeDeadOwnerMarker(owner) {
|
|
568
|
+
if (processIdentityIsLive(owner.pid, owner.processStartTicks)) return;
|
|
569
|
+
const opened = readJsonNoFollow(HANDOFF_OWNER_FILE);
|
|
570
|
+
const payload = opened?.payload;
|
|
571
|
+
if (
|
|
572
|
+
!opened ||
|
|
573
|
+
!hasExactKeys(payload, OWNER_KEYS) ||
|
|
574
|
+
payload.bootId !== owner.bootId ||
|
|
575
|
+
payload.restoreEpochMs !== owner.restoreEpochMs ||
|
|
576
|
+
payload.pid !== owner.pid ||
|
|
577
|
+
payload.processStartTicks !== owner.processStartTicks
|
|
578
|
+
)
|
|
579
|
+
return;
|
|
580
|
+
try {
|
|
581
|
+
const after = fs.lstatSync(HANDOFF_OWNER_FILE);
|
|
582
|
+
if (after.dev === opened.stat.dev && after.ino === opened.stat.ino) {
|
|
583
|
+
fs.unlinkSync(HANDOFF_OWNER_FILE);
|
|
584
|
+
}
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function publicPortBindable() {
|
|
589
|
+
return new Promise(resolve => {
|
|
590
|
+
const server = net.createServer();
|
|
591
|
+
let settled = false;
|
|
592
|
+
const finish = bindable => {
|
|
593
|
+
if (settled) return;
|
|
594
|
+
settled = true;
|
|
595
|
+
server.close(() => resolve(bindable));
|
|
596
|
+
};
|
|
597
|
+
server.once('error', () => resolve(false));
|
|
598
|
+
server.listen(PUBLIC_SERVER_PORT, '127.0.0.1', () => finish(true));
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function waitForPublicPortRelease() {
|
|
603
|
+
while (!stopping) {
|
|
604
|
+
if (await publicPortBindable()) return true;
|
|
605
|
+
await sleep(50);
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
115
608
|
}
|
|
116
609
|
|
|
117
610
|
/**
|
|
118
611
|
* Start and supervise a process with auto-restart and log piping.
|
|
119
612
|
* Returns a promise that resolves when the process loop ends.
|
|
120
613
|
*/
|
|
121
|
-
function startProcess({
|
|
614
|
+
function startProcess({
|
|
615
|
+
name,
|
|
616
|
+
command,
|
|
617
|
+
args,
|
|
618
|
+
cleanupPort,
|
|
619
|
+
environment,
|
|
620
|
+
strictOwnership = false,
|
|
621
|
+
}) {
|
|
122
622
|
const logFilePath = path.join(LOG_DIR, `${name}.std.log`);
|
|
123
623
|
const logFd = fs.openSync(logFilePath, 'a');
|
|
124
624
|
|
|
125
|
-
const entry = {
|
|
625
|
+
const entry = {
|
|
626
|
+
name,
|
|
627
|
+
pid: null,
|
|
628
|
+
child: null,
|
|
629
|
+
processStartTicks: null,
|
|
630
|
+
strictOwnership,
|
|
631
|
+
};
|
|
126
632
|
managedProcesses.push(entry);
|
|
127
633
|
|
|
128
634
|
const run = async () => {
|
|
@@ -134,21 +640,41 @@ function startProcess({ name, command, args, cleanupPort }) {
|
|
|
134
640
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
135
641
|
shell: true,
|
|
136
642
|
cwd: PROJECT_ROOT,
|
|
137
|
-
env: { ...process.env },
|
|
643
|
+
env: { ...process.env, ...environment },
|
|
138
644
|
});
|
|
139
645
|
|
|
140
646
|
entry.pid = child.pid;
|
|
141
647
|
entry.child = child;
|
|
648
|
+
entry.processStartTicks = readLinuxProcessIdentity(
|
|
649
|
+
child.pid
|
|
650
|
+
)?.processStartTicks;
|
|
651
|
+
if (
|
|
652
|
+
strictOwnership &&
|
|
653
|
+
process.platform === 'linux' &&
|
|
654
|
+
!entry.processStartTicks
|
|
655
|
+
) {
|
|
656
|
+
child.kill('SIGKILL');
|
|
657
|
+
throw new Error(`Strict process identity unavailable: ${name}`);
|
|
658
|
+
}
|
|
142
659
|
|
|
143
660
|
const startTime = Date.now();
|
|
144
|
-
logEvent(
|
|
661
|
+
logEvent(
|
|
662
|
+
'INFO',
|
|
663
|
+
name,
|
|
664
|
+
`Process started (PGID: ${child.pid}): ${command} ${args.join(' ')}`
|
|
665
|
+
);
|
|
145
666
|
|
|
146
667
|
// Pipe stdout and stderr through readline for timestamped logging
|
|
147
|
-
const pipeLines =
|
|
148
|
-
const rl = readline.createInterface({
|
|
149
|
-
|
|
668
|
+
const pipeLines = stream => {
|
|
669
|
+
const rl = readline.createInterface({
|
|
670
|
+
input: stream,
|
|
671
|
+
crlfDelay: Infinity,
|
|
672
|
+
});
|
|
673
|
+
rl.on('line', line => {
|
|
150
674
|
const msg = `[${timestamp()}] [${name}] ${line}\n`;
|
|
151
|
-
try {
|
|
675
|
+
try {
|
|
676
|
+
fs.writeSync(logFd, msg);
|
|
677
|
+
} catch {}
|
|
152
678
|
writeOutput(msg);
|
|
153
679
|
});
|
|
154
680
|
};
|
|
@@ -159,25 +685,30 @@ function startProcess({ name, command, args, cleanupPort }) {
|
|
|
159
685
|
// NOTE: must use 'exit', not 'close'. With shell:true, grandchild processes
|
|
160
686
|
// (e.g. nest's server) inherit stdout pipes. 'close' won't fire until ALL
|
|
161
687
|
// pipe holders exit, causing dev.js to hang when npm/nest dies but server survives.
|
|
162
|
-
const exitCode = await new Promise(
|
|
163
|
-
child.on('exit',
|
|
688
|
+
const exitCode = await new Promise(resolve => {
|
|
689
|
+
child.on('exit', code => resolve(code ?? 1));
|
|
164
690
|
child.on('error', () => resolve(1));
|
|
165
691
|
});
|
|
166
692
|
|
|
167
693
|
// Kill the entire process group
|
|
168
694
|
if (entry.pid) {
|
|
169
|
-
|
|
695
|
+
signalManagedProcess(entry, 'SIGTERM');
|
|
170
696
|
await sleep(2000);
|
|
171
|
-
|
|
697
|
+
signalManagedProcess(entry, 'SIGKILL');
|
|
172
698
|
}
|
|
173
699
|
entry.pid = null;
|
|
174
700
|
entry.child = null;
|
|
701
|
+
entry.processStartTicks = null;
|
|
175
702
|
|
|
176
703
|
// Port cleanup fallback
|
|
177
704
|
if (cleanupPort) {
|
|
178
705
|
const orphans = killOrphansByPort(cleanupPort);
|
|
179
706
|
if (orphans.length > 0) {
|
|
180
|
-
logEvent(
|
|
707
|
+
logEvent(
|
|
708
|
+
'WARN',
|
|
709
|
+
name,
|
|
710
|
+
`Killed orphan processes on port ${cleanupPort}: ${orphans.join(' ')}`
|
|
711
|
+
);
|
|
181
712
|
await sleep(500);
|
|
182
713
|
}
|
|
183
714
|
}
|
|
@@ -187,26 +718,133 @@ function startProcess({ name, command, args, cleanupPort }) {
|
|
|
187
718
|
const runDuration = (Date.now() - startTime) / 1000;
|
|
188
719
|
if (runDuration >= 60) {
|
|
189
720
|
restartCount = 0;
|
|
190
|
-
logEvent(
|
|
721
|
+
logEvent(
|
|
722
|
+
'INFO',
|
|
723
|
+
name,
|
|
724
|
+
`Ran for ${Math.round(runDuration)}s, resetting restart counter`
|
|
725
|
+
);
|
|
191
726
|
} else {
|
|
192
727
|
restartCount++;
|
|
193
728
|
}
|
|
194
729
|
if (restartCount >= MAX_RESTART_COUNT) {
|
|
195
|
-
logEvent(
|
|
730
|
+
logEvent(
|
|
731
|
+
'ERROR',
|
|
732
|
+
name,
|
|
733
|
+
`Max restart count (${MAX_RESTART_COUNT}) reached, giving up`
|
|
734
|
+
);
|
|
196
735
|
break;
|
|
197
736
|
}
|
|
198
737
|
|
|
199
|
-
const delay = Math.min(
|
|
200
|
-
|
|
738
|
+
const delay = Math.min(
|
|
739
|
+
RESTART_DELAY * (1 << Math.max(0, restartCount - 1)),
|
|
740
|
+
MAX_DELAY
|
|
741
|
+
);
|
|
742
|
+
logEvent(
|
|
743
|
+
'WARN',
|
|
744
|
+
name,
|
|
745
|
+
`Process exited with code ${exitCode}, restarting (${restartCount}/${MAX_RESTART_COUNT}) in ${delay}s...`
|
|
746
|
+
);
|
|
201
747
|
await sleep(delay * 1000);
|
|
202
748
|
}
|
|
203
749
|
|
|
204
|
-
try {
|
|
750
|
+
try {
|
|
751
|
+
fs.closeSync(logFd);
|
|
752
|
+
} catch {}
|
|
205
753
|
};
|
|
206
754
|
|
|
207
755
|
return run();
|
|
208
756
|
}
|
|
209
757
|
|
|
758
|
+
function signalManagedProcess(entry, signal) {
|
|
759
|
+
if (!entry.pid) return;
|
|
760
|
+
if (entry.strictOwnership && process.platform === 'linux') {
|
|
761
|
+
if (
|
|
762
|
+
!ownedGroupIdentityMatches({
|
|
763
|
+
pid: entry.pid,
|
|
764
|
+
processStartTicks: entry.processStartTicks,
|
|
765
|
+
})
|
|
766
|
+
) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
killProcessGroup(entry.pid, signal);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function initializeActionPlugins() {
|
|
774
|
+
writeOutput('\n🔌 Initializing action plugins...\n');
|
|
775
|
+
try {
|
|
776
|
+
execSync('fullstack-cli action-plugin init', {
|
|
777
|
+
cwd: PROJECT_ROOT,
|
|
778
|
+
stdio: 'inherit',
|
|
779
|
+
});
|
|
780
|
+
writeOutput('✅ Action plugins initialized\n\n');
|
|
781
|
+
} catch {
|
|
782
|
+
writeOutput(
|
|
783
|
+
'⚠️ Action plugin initialization failed, continuing anyway...\n\n'
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
let handoffFailbackStarted = false;
|
|
789
|
+
|
|
790
|
+
async function startHandoffFailback(owner, reason) {
|
|
791
|
+
if (handoffFailbackStarted || stopping) return;
|
|
792
|
+
handoffFailbackStarted = true;
|
|
793
|
+
logEvent(
|
|
794
|
+
'ERROR',
|
|
795
|
+
'handoff-watchdog',
|
|
796
|
+
`Backend owner lost (${reason}); taking over the public server lifecycle`
|
|
797
|
+
);
|
|
798
|
+
await terminateStrictRuntimeOwner(owner);
|
|
799
|
+
await cleanupDeadRuntimeRegistry(owner);
|
|
800
|
+
removeDeadOwnerMarker(owner);
|
|
801
|
+
if (!(await waitForPublicPortRelease())) return;
|
|
802
|
+
initializeActionPlugins();
|
|
803
|
+
void startProcess({
|
|
804
|
+
name: 'server',
|
|
805
|
+
command: 'npm',
|
|
806
|
+
args: ['run', 'dev:server'],
|
|
807
|
+
environment: {
|
|
808
|
+
SERVER_HOST: '127.0.0.1',
|
|
809
|
+
SERVER_PORT: String(PUBLIC_SERVER_PORT),
|
|
810
|
+
},
|
|
811
|
+
strictOwnership: true,
|
|
812
|
+
// Never use lsof/pkill in handoff failback. The strict per-boot registry is
|
|
813
|
+
// the only authority for removing the dead runtime's children.
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
async function monitorHandoffOwner(initialOwner) {
|
|
818
|
+
let expectedOwner = initialOwner;
|
|
819
|
+
let identityFailures = 0;
|
|
820
|
+
let portFailures = 0;
|
|
821
|
+
let stableSuccesses = 0;
|
|
822
|
+
while (!stopping && !handoffFailbackStarted) {
|
|
823
|
+
const currentOwner = readStrictHandoffOwner();
|
|
824
|
+
const reachable = await publicServerReachable();
|
|
825
|
+
if (currentOwner) {
|
|
826
|
+
expectedOwner = currentOwner;
|
|
827
|
+
identityFailures = 0;
|
|
828
|
+
} else {
|
|
829
|
+
identityFailures += 1;
|
|
830
|
+
}
|
|
831
|
+
portFailures = reachable ? 0 : portFailures + 1;
|
|
832
|
+
stableSuccesses = currentOwner && reachable ? stableSuccesses + 1 : 0;
|
|
833
|
+
if (identityFailures >= 2 || portFailures >= 5) {
|
|
834
|
+
await startHandoffFailback(
|
|
835
|
+
expectedOwner,
|
|
836
|
+
identityFailures >= 2
|
|
837
|
+
? 'owner-identity-stale'
|
|
838
|
+
: 'public-port-unreachable'
|
|
839
|
+
);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// Stay responsive while establishing health or after the first failure,
|
|
843
|
+
// then reduce steady-state readiness traffic from 5 QPS to below 1 QPS.
|
|
844
|
+
await sleep(stableSuccesses >= 3 ? 1500 : 200);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
210
848
|
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
|
211
849
|
let cleanupDone = false;
|
|
212
850
|
|
|
@@ -221,7 +859,7 @@ async function cleanup() {
|
|
|
221
859
|
for (const entry of managedProcesses) {
|
|
222
860
|
if (entry.pid) {
|
|
223
861
|
logEvent('INFO', 'main', `Stopping process group (PGID: ${entry.pid})`);
|
|
224
|
-
|
|
862
|
+
signalManagedProcess(entry, 'SIGTERM');
|
|
225
863
|
}
|
|
226
864
|
}
|
|
227
865
|
|
|
@@ -231,19 +869,27 @@ async function cleanup() {
|
|
|
231
869
|
// Force kill any remaining
|
|
232
870
|
for (const entry of managedProcesses) {
|
|
233
871
|
if (entry.pid) {
|
|
234
|
-
logEvent(
|
|
235
|
-
|
|
872
|
+
logEvent(
|
|
873
|
+
'WARN',
|
|
874
|
+
'main',
|
|
875
|
+
`Force killing process group (PGID: ${entry.pid})`
|
|
876
|
+
);
|
|
877
|
+
signalManagedProcess(entry, 'SIGKILL');
|
|
236
878
|
}
|
|
237
879
|
}
|
|
238
880
|
|
|
239
881
|
// Port cleanup fallback
|
|
240
|
-
killOrphansByPort(
|
|
882
|
+
if (!BACKEND_OWNED_BY_HANDOFF) killOrphansByPort(LEGACY_SERVER_PORT);
|
|
241
883
|
killOrphansByPort(CLIENT_DEV_PORT);
|
|
242
884
|
|
|
243
885
|
logEvent('INFO', 'main', 'All processes stopped');
|
|
244
886
|
|
|
245
|
-
try {
|
|
246
|
-
|
|
887
|
+
try {
|
|
888
|
+
fs.closeSync(devStdLogFd);
|
|
889
|
+
} catch {}
|
|
890
|
+
try {
|
|
891
|
+
fs.closeSync(devLogFd);
|
|
892
|
+
} catch {}
|
|
247
893
|
|
|
248
894
|
process.exit(0);
|
|
249
895
|
}
|
|
@@ -267,22 +913,35 @@ async function main() {
|
|
|
267
913
|
|
|
268
914
|
cleanStaleDist();
|
|
269
915
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
916
|
+
const initialHandoffOwner = BACKEND_OWNED_BY_HANDOFF
|
|
917
|
+
? readStrictHandoffOwner()
|
|
918
|
+
: null;
|
|
919
|
+
const handoffOwnsBackend = Boolean(initialHandoffOwner);
|
|
920
|
+
if (BACKEND_OWNED_BY_HANDOFF && !handoffOwnsBackend) {
|
|
921
|
+
logEvent(
|
|
922
|
+
'WARN',
|
|
923
|
+
'handoff-watchdog',
|
|
924
|
+
'Static handoff claim has no matching live owner; using legacy backend ownership'
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (!handoffOwnsBackend) {
|
|
929
|
+
// Initialize action plugins. In handoff mode the backend runtime owns this
|
|
930
|
+
// predecessor and the source process as one lifecycle.
|
|
931
|
+
initializeActionPlugins();
|
|
277
932
|
}
|
|
278
933
|
|
|
279
934
|
// Start server and client
|
|
280
|
-
const serverPromise =
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
935
|
+
const serverPromise = handoffOwnsBackend
|
|
936
|
+
? Promise.resolve()
|
|
937
|
+
: startProcess({
|
|
938
|
+
name: 'server',
|
|
939
|
+
command: 'npm',
|
|
940
|
+
args: ['run', 'dev:server'],
|
|
941
|
+
cleanupPort: BACKEND_OWNED_BY_HANDOFF ? undefined : LEGACY_SERVER_PORT,
|
|
942
|
+
strictOwnership: BACKEND_OWNED_BY_HANDOFF,
|
|
943
|
+
});
|
|
944
|
+
if (handoffOwnsBackend) void monitorHandoffOwner(initialHandoffOwner);
|
|
286
945
|
|
|
287
946
|
const clientPromise = startProcess({
|
|
288
947
|
name: 'client',
|
|
@@ -302,7 +961,7 @@ async function main() {
|
|
|
302
961
|
}
|
|
303
962
|
}
|
|
304
963
|
|
|
305
|
-
main().catch(
|
|
964
|
+
main().catch(err => {
|
|
306
965
|
console.error('Fatal error:', err);
|
|
307
966
|
process.exit(1);
|
|
308
967
|
});
|