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