@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.
@@ -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 = 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;
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
- 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') + ':' +
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 { fs.writeSync(devStdLogFd, msg); } catch {}
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) { return; }
137
+ if (_stdoutInFlight >= STDOUT_MAX_INFLIGHT) {
138
+ return;
139
+ }
77
140
  _stdoutInFlight++;
78
- try { fs.write(1, msg, () => { _stdoutInFlight--; }); } catch { _stdoutInFlight--; }
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 { fs.writeSync(devLogFd, msg); } catch {}
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}`, { encoding: 'utf8', timeout: 5000 }).trim();
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 { process.kill(parseInt(p, 10), 'SIGKILL'); } catch {}
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((resolve) => setTimeout(resolve, ms));
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({ name, command, args, cleanupPort }) {
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 = { name, pid: null, child: null };
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('INFO', name, `Process started (PGID: ${child.pid}): ${command} ${args.join(' ')}`);
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 = (stream) => {
148
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
149
- rl.on('line', (line) => {
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 { fs.writeSync(logFd, msg); } catch {}
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((resolve) => {
163
- child.on('exit', (code) => resolve(code ?? 1));
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
- killProcessGroup(entry.pid, 'SIGTERM');
695
+ signalManagedProcess(entry, 'SIGTERM');
170
696
  await sleep(2000);
171
- killProcessGroup(entry.pid, 'SIGKILL');
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('WARN', name, `Killed orphan processes on port ${cleanupPort}: ${orphans.join(' ')}`);
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('INFO', name, `Ran for ${Math.round(runDuration)}s, resetting restart counter`);
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('ERROR', name, `Max restart count (${MAX_RESTART_COUNT}) reached, giving up`);
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(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...`);
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 { fs.closeSync(logFd); } catch {}
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
- killProcessGroup(entry.pid, 'SIGTERM');
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('WARN', 'main', `Force killing process group (PGID: ${entry.pid})`);
235
- killProcessGroup(entry.pid, 'SIGKILL');
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(SERVER_PORT);
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 { fs.closeSync(devStdLogFd); } catch {}
246
- try { fs.closeSync(devLogFd); } catch {}
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
- // 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');
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 = startProcess({
281
- name: 'server',
282
- command: 'npm',
283
- args: ['run', 'dev:server'],
284
- cleanupPort: SERVER_PORT,
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((err) => {
964
+ main().catch(err => {
306
965
  console.error('Fatal error:', err);
307
966
  process.exit(1);
308
967
  });