@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.
@@ -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 = process.env.MAX_RESTART_COUNT != null && process.env.MAX_RESTART_COUNT !== ''
35
- ? parseInt(process.env.MAX_RESTART_COUNT, 10)
36
- : Infinity;
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 SERVER_PORT = process.env.SERVER_PORT || '3000';
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
- String(now.getMonth() + 1).padStart(2, '0') + '-' +
55
- String(now.getDate()).padStart(2, '0') + ' ' +
56
- String(now.getHours()).padStart(2, '0') + ':' +
57
- String(now.getMinutes()).padStart(2, '0') + ':' +
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 { fs.writeSync(devStdLogFd, msg); } catch {}
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) { return; }
126
+ if (_stdoutInFlight >= STDOUT_MAX_INFLIGHT) {
127
+ return;
128
+ }
77
129
  _stdoutInFlight++;
78
- try { fs.write(1, msg, () => { _stdoutInFlight--; }); } catch { _stdoutInFlight--; }
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 { fs.writeSync(devLogFd, msg); } catch {}
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}`, { encoding: 'utf8', timeout: 5000 }).trim();
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 { process.kill(parseInt(p, 10), 'SIGKILL'); } catch {}
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((resolve) => setTimeout(resolve, ms));
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({ name, command, args, cleanupPort }) {
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 = { name, pid: null, child: null };
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('INFO', name, `Process started (PGID: ${child.pid}): ${command} ${args.join(' ')}`);
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 = (stream) => {
148
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
149
- rl.on('line', (line) => {
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 { fs.writeSync(logFd, msg); } catch {}
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((resolve) => {
163
- child.on('exit', (code) => resolve(code ?? 1));
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
- killProcessGroup(entry.pid, 'SIGTERM');
677
+ signalManagedProcess(entry, 'SIGTERM');
170
678
  await sleep(2000);
171
- killProcessGroup(entry.pid, 'SIGKILL');
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('WARN', name, `Killed orphan processes on port ${cleanupPort}: ${orphans.join(' ')}`);
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('INFO', name, `Ran for ${Math.round(runDuration)}s, resetting restart counter`);
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('ERROR', name, `Max restart count (${MAX_RESTART_COUNT}) reached, giving up`);
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(RESTART_DELAY * (1 << Math.max(0, restartCount - 1)), MAX_DELAY);
200
- logEvent('WARN', name, `Process exited with code ${exitCode}, restarting (${restartCount}/${MAX_RESTART_COUNT}) in ${delay}s...`);
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 { fs.closeSync(logFd); } catch {}
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
- killProcessGroup(entry.pid, 'SIGTERM');
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('WARN', 'main', `Force killing process group (PGID: ${entry.pid})`);
235
- killProcessGroup(entry.pid, 'SIGKILL');
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(SERVER_PORT);
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 { fs.closeSync(devStdLogFd); } catch {}
246
- try { fs.closeSync(devLogFd); } catch {}
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
- // Initialize action plugins
271
- writeOutput('\n🔌 Initializing action plugins...\n');
272
- try {
273
- execSync('fullstack-cli action-plugin init', { cwd: PROJECT_ROOT, stdio: 'inherit' });
274
- writeOutput('✅ Action plugins initialized\n\n');
275
- } catch {
276
- writeOutput('⚠️ Action plugin initialization failed, continuing anyway...\n\n');
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 = startProcess({
281
- name: 'server',
282
- command: 'npm',
283
- args: ['run', 'dev:server'],
284
- cleanupPort: SERVER_PORT,
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((err) => {
946
+ main().catch(err => {
306
947
  console.error('Fatal error:', err);
307
948
  process.exit(1);
308
949
  });