@hienlh/ppm 0.17.49 → 0.17.50
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/CHANGELOG.md +3668 -3662
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/docs/lessons-learned.md +69 -0
- package/docs/system-architecture.md +14 -3
- package/package.json +106 -106
- package/src/cli/commands/restart.ts +39 -23
- package/src/cli/commands/stop.ts +5 -0
- package/src/index.ts +7 -1
- package/src/server/index.ts +14 -1
- package/src/services/supervisor-stopped-page.ts +20 -3
- package/src/services/supervisor.ts +248 -61
|
@@ -26,6 +26,9 @@ import {
|
|
|
26
26
|
findPortListenerPid, isPpmProcess, collectProcessTree, terminateTree,
|
|
27
27
|
} from "./windows-process-tree.ts";
|
|
28
28
|
import { reapZombiePortOrphans } from "./windows-zombie-port-reaper.ts";
|
|
29
|
+
import {
|
|
30
|
+
SERVER_PORT_FILE, resolveTargetPort, _resetTargetCache,
|
|
31
|
+
} from "./edge-target-resolver.ts";
|
|
29
32
|
import { PLIST_LABEL } from "./autostart-generator.ts";
|
|
30
33
|
|
|
31
34
|
// ─── Constants ─────────────────────────────────────────────────────────
|
|
@@ -43,6 +46,8 @@ const TUNNEL_URL_REGEX = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/;
|
|
|
43
46
|
const UPGRADE_CHECK_INTERVAL_MS = 900_000; // 15min
|
|
44
47
|
const UPGRADE_SKIP_INITIAL_MS = 300_000; // 5min delay before first check
|
|
45
48
|
const SELF_REPLACE_TIMEOUT_MS = 30_000; // 30s to wait for new supervisor
|
|
49
|
+
const EDGE_PROBE_INTERVAL_MS = 10_000; // the public port is dark while the edge is down — check often
|
|
50
|
+
const SERVER_PORT_MIRROR_TIMEOUT_MS = 30_000; // how long to wait for the server to publish its port
|
|
46
51
|
|
|
47
52
|
const logFile = () => resolve(getPpmDir(), "ppm.log");
|
|
48
53
|
const restartingFlag = () => resolve(getPpmDir(), ".restarting");
|
|
@@ -54,6 +59,10 @@ let tunnelChild: Subprocess | null = null;
|
|
|
54
59
|
let tunnelUrl: string | null = null;
|
|
55
60
|
let tunnelPort: number | null = null; // origin port the live tunnel targets
|
|
56
61
|
let adoptedTunnelPid: number | null = null; // PID of tunnel kept alive across upgrade
|
|
62
|
+
// PID of the edge forwarder. Like the tunnel it is spawned detached so it
|
|
63
|
+
// survives self-replace, so a new supervisor adopts it rather than respawning.
|
|
64
|
+
let edgePid: number | null = null;
|
|
65
|
+
let edgeProbeTimer: ReturnType<typeof setInterval> | null = null;
|
|
57
66
|
let shuttingDown = false;
|
|
58
67
|
// Monotonic token for the authoritative tunnel loop. Every EXTERNAL (re)start
|
|
59
68
|
// bumps it; a loop whose captured generation is stale must exit instead of
|
|
@@ -306,6 +315,123 @@ function requestServerShutdown(child: Subprocess, timeoutMs: number = 2000): Pro
|
|
|
306
315
|
});
|
|
307
316
|
}
|
|
308
317
|
|
|
318
|
+
// ─── Edge forwarder management ─────────────────────────────────────────
|
|
319
|
+
// The edge owns the PUBLIC port and forwards to the server's loopback port.
|
|
320
|
+
// It exists so the server never needs a stable port: cloudflared stays pinned
|
|
321
|
+
// to the edge, so a server port move can no longer rotate the public URL.
|
|
322
|
+
// The edge spawns no children, so unlike the server its listening socket can
|
|
323
|
+
// never be inherited and its port can never zombie.
|
|
324
|
+
|
|
325
|
+
/** Argv for re-invoking this binary (or source tree) as the edge process. */
|
|
326
|
+
function edgeCmd(publicPort: number, host: string): string[] {
|
|
327
|
+
const args = ["__edge__", String(publicPort), host];
|
|
328
|
+
return isCompiledBinary()
|
|
329
|
+
? [process.execPath, ...args]
|
|
330
|
+
: [process.execPath, "run", resolve(import.meta.dir, "edge-forwarder.ts"), ...args];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Spawn the edge detached so it outlives this supervisor's self-replace.
|
|
335
|
+
*
|
|
336
|
+
* `Bun.spawn` would tie it to the supervisor's job object on Windows and kill
|
|
337
|
+
* it the moment the old supervisor exits during an upgrade — the same reason
|
|
338
|
+
* spawnTunnel uses node's detached spawn. Gated on the probe mutex: fd stdio
|
|
339
|
+
* enables handle inheritance, and spawning while a port probe is open would
|
|
340
|
+
* hand the edge a listener handle.
|
|
341
|
+
*/
|
|
342
|
+
async function spawnEdge(publicPort: number, host: string, logFd: number): Promise<void> {
|
|
343
|
+
const [bin, ...args] = edgeCmd(publicPort, host);
|
|
344
|
+
const { spawn: nodeSpawn } = require("node:child_process") as typeof import("node:child_process");
|
|
345
|
+
const proc = await withProbeSpawnGate(() => nodeSpawn(bin!, args, {
|
|
346
|
+
detached: true,
|
|
347
|
+
windowsHide: true,
|
|
348
|
+
stdio: ["ignore", "ignore", logFd] as ["ignore", "ignore", number],
|
|
349
|
+
}));
|
|
350
|
+
proc.unref();
|
|
351
|
+
edgePid = proc.pid ?? null;
|
|
352
|
+
updateStatus({ edgePid });
|
|
353
|
+
log("INFO", `Edge forwarder started on ${host}:${publicPort} (PID: ${edgePid}, detached)`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Adopt an edge kept alive across an upgrade.
|
|
358
|
+
*
|
|
359
|
+
* Liveness alone is not proof of identity — Windows reuses PIDs, and adopting a
|
|
360
|
+
* recycled PID would leave the public port unserved with the supervisor
|
|
361
|
+
* believing all is well. Require that the PID is the one actually listening on
|
|
362
|
+
* the public port.
|
|
363
|
+
*/
|
|
364
|
+
async function adoptEdge(publicPort: number, host: string): Promise<boolean> {
|
|
365
|
+
const pid = readStatus().edgePid as number | undefined;
|
|
366
|
+
if (!pid) return false;
|
|
367
|
+
try {
|
|
368
|
+
process.kill(pid, 0); // throws if dead
|
|
369
|
+
} catch {
|
|
370
|
+
log("INFO", `adoptEdge: recorded edge PID ${pid} is dead`);
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const listener = findPortListenerPid(publicPort);
|
|
375
|
+
if (listener > 0 && listener !== pid) {
|
|
376
|
+
log("WARN", `adoptEdge: PID ${pid} alive but port ${publicPort} is held by PID ${listener} — not adopting`);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
if (listener === 0) {
|
|
380
|
+
// Listener unknown, not "absent": findPortListenerPid needs netstat on
|
|
381
|
+
// Windows and lsof on POSIX, and returns 0 when the tool is missing or the
|
|
382
|
+
// lookup fails. Treating that as a mismatch would refuse every adoption on
|
|
383
|
+
// such a box and spawn a duplicate edge that then cannot bind. Fall back to
|
|
384
|
+
// the weaker but decisive question: is anything holding the port at all?
|
|
385
|
+
if (await isPortBindable(publicPort, host)) {
|
|
386
|
+
log("INFO", `adoptEdge: PID ${pid} alive but port ${publicPort} is free — stale record, not adopting`);
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
log("DEBUG", `adoptEdge: cannot identify the listener on ${publicPort}; port is occupied and PID ${pid} is alive — adopting`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
edgePid = pid;
|
|
393
|
+
log("INFO", `Adopted existing edge forwarder (PID: ${pid}, port: ${publicPort})`);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Respawn the edge if it dies. Without it the public port simply goes dark. */
|
|
398
|
+
function startEdgeProbe(publicPort: number, host: string, logFd: number) {
|
|
399
|
+
if (edgeProbeTimer) return;
|
|
400
|
+
edgeProbeTimer = setInterval(() => {
|
|
401
|
+
if (shuttingDown || getState() === "upgrading" || !edgePid) return;
|
|
402
|
+
try {
|
|
403
|
+
process.kill(edgePid, 0);
|
|
404
|
+
} catch {
|
|
405
|
+
log("WARN", `Edge forwarder (PID: ${edgePid}) died — respawning`);
|
|
406
|
+
edgePid = null;
|
|
407
|
+
void spawnEdge(publicPort, host, logFd).catch((e) =>
|
|
408
|
+
log("ERROR", `Edge respawn failed: ${e}`));
|
|
409
|
+
}
|
|
410
|
+
}, EDGE_PROBE_INTERVAL_MS);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Copy the port the server published into status.json.
|
|
415
|
+
*
|
|
416
|
+
* Observability only — `ppm status` and the CLI health probe read it. The edge
|
|
417
|
+
* reads `.server-port` directly so it never depends on this, or on the
|
|
418
|
+
* supervisor being alive at all.
|
|
419
|
+
*/
|
|
420
|
+
async function mirrorServerPort(): Promise<void> {
|
|
421
|
+
const deadline = Date.now() + SERVER_PORT_MIRROR_TIMEOUT_MS;
|
|
422
|
+
while (Date.now() < deadline) {
|
|
423
|
+
_resetTargetCache(); // the memo is for the forwarder's hot path, not this poll
|
|
424
|
+
const port = resolveTargetPort();
|
|
425
|
+
if (port !== null) {
|
|
426
|
+
updateStatus({ serverPort: port });
|
|
427
|
+
log("INFO", `Server bound loopback port ${port}`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
await Bun.sleep(200);
|
|
431
|
+
}
|
|
432
|
+
log("WARN", "Server never published its port — status.serverPort left stale");
|
|
433
|
+
}
|
|
434
|
+
|
|
309
435
|
// ─── Server management ─────────────────────────────────────────────────
|
|
310
436
|
export async function spawnServer(
|
|
311
437
|
serverArgs: string[],
|
|
@@ -319,34 +445,19 @@ export async function spawnServer(
|
|
|
319
445
|
await reapTrackedDescendants((m) => log("INFO", m)).catch(() => {});
|
|
320
446
|
}
|
|
321
447
|
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if (boundPort !== _opts.port) {
|
|
336
|
-
_opts.port = boundPort;
|
|
337
|
-
serverArgs[1] = String(boundPort); // serverArgs = ["__serve__", <port>, <host>, ...]
|
|
338
|
-
updateStatus({ port: boundPort });
|
|
339
|
-
log("WARN", `Server port moved to ${boundPort}`);
|
|
340
|
-
}
|
|
341
|
-
// Re-point the tunnel only when its origin differs from the port we bound.
|
|
342
|
-
// (tunnelPort === null means an old-format adoption without origin info —
|
|
343
|
-
// fall back to restarting whenever the bind moved off the preferred port.)
|
|
344
|
-
if (tunnelAlive && tunnelPort !== null && tunnelPort !== boundPort) {
|
|
345
|
-
restartTunnel(boundPort);
|
|
346
|
-
} else if (tunnelAlive && tunnelPort === null && boundPort !== preferred) {
|
|
347
|
-
restartTunnel(boundPort);
|
|
348
|
-
}
|
|
349
|
-
|
|
448
|
+
// The server binds an OS-assigned loopback port (`__serve__ 0 127.0.0.1`) and
|
|
449
|
+
// publishes it to `.server-port`; the edge forwards the public port to it.
|
|
450
|
+
//
|
|
451
|
+
// There is deliberately no port negotiation here any more. The old code
|
|
452
|
+
// preferred the live tunnel's origin port and fell back to a nearby port when
|
|
453
|
+
// a zombie socket held it — and that fallback re-pointed the tunnel, which is
|
|
454
|
+
// what rotated the public URL on every upgrade. A server that needs no
|
|
455
|
+
// particular port cannot trigger that, and cannot drift 3212→3213→3214.
|
|
456
|
+
// Zombie-port handling now applies only to the edge's public port.
|
|
457
|
+
//
|
|
458
|
+
// Clear the stale port file so the mirror below cannot publish the previous
|
|
459
|
+
// generation's port to `ppm status`.
|
|
460
|
+
try { unlinkSync(SERVER_PORT_FILE()); } catch {}
|
|
350
461
|
const cmd = isCompiledBinary()
|
|
351
462
|
? [process.execPath, ...serverArgs]
|
|
352
463
|
: [process.execPath, "run", resolve(import.meta.dir, "..", "server", "index.ts"), ...serverArgs];
|
|
@@ -366,6 +477,7 @@ export async function spawnServer(
|
|
|
366
477
|
updateStatus({ pid: childPid });
|
|
367
478
|
writeFileSync(PID_FILE(), String(process.pid)); // supervisor PID for stop
|
|
368
479
|
log("INFO", `Server started (PID: ${childPid})`);
|
|
480
|
+
void mirrorServerPort();
|
|
369
481
|
|
|
370
482
|
const exitCode = await serverChild.exited;
|
|
371
483
|
serverChild = null;
|
|
@@ -672,15 +784,18 @@ function startServerHealthCheck() {
|
|
|
672
784
|
return;
|
|
673
785
|
}
|
|
674
786
|
noServerChildCycles = 0;
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
787
|
+
// Probe the SERVER's own loopback port, never `_opts.port` — that is the
|
|
788
|
+
// public port and belongs to the edge. Probing through the edge would make
|
|
789
|
+
// a dead edge look like a dead server and kill a perfectly healthy one
|
|
790
|
+
// every third cycle. Edge liveness is startEdgeProbe's job.
|
|
791
|
+
_resetTargetCache();
|
|
792
|
+
const checkPort = resolveTargetPort();
|
|
793
|
+
if (checkPort === null) {
|
|
794
|
+
// Server has not published a port yet (still booting, or just respawned).
|
|
795
|
+
// Absence of a port is not evidence of ill health.
|
|
796
|
+
healthFailCount = 0;
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
684
799
|
try {
|
|
685
800
|
const res = await fetch(`http://127.0.0.1:${checkPort}/api/health`, {
|
|
686
801
|
signal: AbortSignal.timeout(5000),
|
|
@@ -922,30 +1037,40 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
|
|
|
922
1037
|
// The tree-kill above already reaped the server's grandchildren, so the
|
|
923
1038
|
// listening socket is released; this loop just waits for the OS to finish
|
|
924
1039
|
// tearing it down before the new supervisor binds.
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1040
|
+
// Only relevant when NO edge is running — i.e. migrating from a pre-edge
|
|
1041
|
+
// build where the server itself held the public port. With an edge alive
|
|
1042
|
+
// the port is legitimately occupied by it and the new supervisor adopts it;
|
|
1043
|
+
// waiting for the port to free would time out, and the tree-kill below
|
|
1044
|
+
// would murder the edge and rotate the public URL — the exact failure this
|
|
1045
|
+
// whole design removes.
|
|
1046
|
+
if (!edgePid) {
|
|
1047
|
+
const portFreeStart = Date.now();
|
|
1048
|
+
const portTimeout = process.platform === "win32" ? 3_000 : 10_000;
|
|
1049
|
+
while (Date.now() - portFreeStart < portTimeout) {
|
|
1050
|
+
const inUse = !(await isPortBindable(_opts.port, _opts.host));
|
|
1051
|
+
if (!inUse) break;
|
|
1052
|
+
log("DEBUG", `Port ${_opts.port} still in use, waiting...`);
|
|
1053
|
+
await Bun.sleep(200);
|
|
1054
|
+
}
|
|
933
1055
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1056
|
+
// Windows: the tracked-descendant snapshot can miss an orphan (an SDK
|
|
1057
|
+
// grandchild spawned after the last snapshot, or whose parent chain already
|
|
1058
|
+
// broke). If it still holds the inherited listening socket, the new
|
|
1059
|
+
// supervisor can never bind. Resolve the real holder via netstat and
|
|
1060
|
+
// tree-kill it so the handoff doesn't dead-end on a zombie port.
|
|
1061
|
+
if (process.platform === "win32") {
|
|
1062
|
+
const stillInUse = !(await isPortBindable(_opts.port, _opts.host));
|
|
1063
|
+
if (stillInUse) {
|
|
1064
|
+
const holderPid = findPortListenerPid(_opts.port);
|
|
1065
|
+
if (holderPid > 0) {
|
|
1066
|
+
log("WARN", `Port ${_opts.port} still held by PID ${holderPid} before self-replace — tree-killing`);
|
|
1067
|
+
killProcessTree(holderPid);
|
|
1068
|
+
await Bun.sleep(500);
|
|
1069
|
+
}
|
|
947
1070
|
}
|
|
948
1071
|
}
|
|
1072
|
+
} else {
|
|
1073
|
+
log("INFO", `Edge forwarder (PID: ${edgePid}) holds port ${_opts.port} — leaving it for the new supervisor to adopt`);
|
|
949
1074
|
}
|
|
950
1075
|
|
|
951
1076
|
// Spawn new supervisor using saved argv
|
|
@@ -1226,7 +1351,9 @@ export async function softStop() {
|
|
|
1226
1351
|
|
|
1227
1352
|
// Keep: tunnel, Cloud WS, upgrade checks, tunnel probe
|
|
1228
1353
|
updateStatus({ state: "stopped", pid: null, stoppedAt: new Date().toISOString() });
|
|
1229
|
-
|
|
1354
|
+
// Loopback + OS-assigned: it publishes itself to `.server-port` and the edge
|
|
1355
|
+
// routes the public port to it, so the tunnel URL keeps serving.
|
|
1356
|
+
startStoppedPage(0, "127.0.0.1");
|
|
1230
1357
|
|
|
1231
1358
|
// Wait for resume signal
|
|
1232
1359
|
await waitForResume();
|
|
@@ -1283,6 +1410,15 @@ export function shutdown() {
|
|
|
1283
1410
|
log("INFO", `Killing adopted tunnel (PID: ${adoptedTunnelPid})`);
|
|
1284
1411
|
try { process.kill(adoptedTunnelPid, "SIGKILL"); } catch {}
|
|
1285
1412
|
}
|
|
1413
|
+
// Same treatment as the tunnel: the edge is detached, so a plain supervisor
|
|
1414
|
+
// exit would leave it holding the public port. The self-replace upgrade path
|
|
1415
|
+
// exits without calling shutdown(), which is exactly why the edge survives
|
|
1416
|
+
// an upgrade but not a stop.
|
|
1417
|
+
if (edgePid) {
|
|
1418
|
+
log("INFO", `Killing edge forwarder (PID: ${edgePid})`);
|
|
1419
|
+
try { process.kill(edgePid, "SIGKILL"); } catch {}
|
|
1420
|
+
edgePid = null;
|
|
1421
|
+
}
|
|
1286
1422
|
}
|
|
1287
1423
|
|
|
1288
1424
|
// ─── Main entry ────────────────────────────────────────────────────────
|
|
@@ -1363,15 +1499,22 @@ export async function runSupervisor(opts: {
|
|
|
1363
1499
|
tunnelPid: isUpgrade ? (prevStatus.tunnelPid ?? null) : null,
|
|
1364
1500
|
shareUrl: isUpgrade ? (prevStatus.shareUrl ?? null) : null,
|
|
1365
1501
|
tunnelPort: isUpgrade ? (prevStatus.tunnelPort ?? null) : null,
|
|
1502
|
+
// The edge is detached and survives self-replace exactly like the tunnel,
|
|
1503
|
+
// so its PID must survive this wholesale rewrite or the new supervisor
|
|
1504
|
+
// would spawn a second edge and collide on the public port.
|
|
1505
|
+
edgePid: isUpgrade ? (prevStatus.edgePid ?? null) : null,
|
|
1506
|
+
serverPort: null, // republished by the server on every spawn
|
|
1366
1507
|
});
|
|
1367
1508
|
// Diagnostic: a cold start (isUpgrade=false) always nulls the tunnel and forces
|
|
1368
1509
|
// a fresh URL. A genuine upgrade must arrive here with state "upgrading" AND a
|
|
1369
1510
|
// live tunnelPid for the public URL to survive — log both to catch which path ran.
|
|
1370
1511
|
log("INFO", `Startup: isUpgrade=${isUpgrade} prevState=${prevStatus.state} prevTunnelPid=${prevStatus.tunnelPid ?? null} prevShareUrl=${prevStatus.shareUrl ?? null}`);
|
|
1371
1512
|
|
|
1372
|
-
// Build __serve__ args
|
|
1513
|
+
// Build __serve__ args. Port 0 = OS-assigned, bound to loopback only: the
|
|
1514
|
+
// edge is the sole public listener, and the server publishes whatever port it
|
|
1515
|
+
// got to `.server-port`.
|
|
1373
1516
|
const serverArgs = [
|
|
1374
|
-
"__serve__",
|
|
1517
|
+
"__serve__", "0", "127.0.0.1",
|
|
1375
1518
|
opts.profile ?? "",
|
|
1376
1519
|
];
|
|
1377
1520
|
// Strip trailing empty args
|
|
@@ -1570,6 +1713,50 @@ export async function runSupervisor(opts: {
|
|
|
1570
1713
|
await reapOrphanedTunnels(tunnelAdopted ? adoptedTunnelPid : null);
|
|
1571
1714
|
}
|
|
1572
1715
|
|
|
1716
|
+
// The edge owns the public port, so it must exist before anything else tries
|
|
1717
|
+
// to use that port — and it must be started BEFORE the server child. The
|
|
1718
|
+
// edge's listening socket lives in the edge process, so a server spawned
|
|
1719
|
+
// afterwards cannot inherit it; reversing this order would put the socket in
|
|
1720
|
+
// reach of the server's chat/tool/MCP descendants and reintroduce the very
|
|
1721
|
+
// zombie-port failure the edge exists to prevent.
|
|
1722
|
+
//
|
|
1723
|
+
// Prefer the port the adopted tunnel already points at: binding anywhere else
|
|
1724
|
+
// would strand cloudflared on a dead origin and force a URL rotation.
|
|
1725
|
+
const publicPort = tunnelAdopted && tunnelPort !== null ? tunnelPort : opts.port;
|
|
1726
|
+
if (publicPort !== opts.port) {
|
|
1727
|
+
log("INFO", `Edge takes tunnel origin port ${publicPort} over configured ${opts.port} (public URL continuity)`);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Adoption MUST be attempted before any bind probe. `ensureBindablePort`
|
|
1731
|
+
// treats a PPM process holding the port as debris to reclaim and tree-kills
|
|
1732
|
+
// it — which, for a healthy adopted edge, would destroy the one thing keeping
|
|
1733
|
+
// the public URL alive across the upgrade. Probe only when there is no edge.
|
|
1734
|
+
let boundPublicPort = publicPort;
|
|
1735
|
+
if (!(await adoptEdge(publicPort, opts.host))) {
|
|
1736
|
+
boundPublicPort = await ensureBindablePort(publicPort, opts.host);
|
|
1737
|
+
if (boundPublicPort !== publicPort) {
|
|
1738
|
+
log("WARN", `Public port ${publicPort} unbindable — edge moved to ${boundPublicPort}. This is now the ONLY thing that can rotate the public URL.`);
|
|
1739
|
+
}
|
|
1740
|
+
await spawnEdge(boundPublicPort, opts.host, logFd);
|
|
1741
|
+
}
|
|
1742
|
+
_opts.port = boundPublicPort;
|
|
1743
|
+
updateStatus({ port: boundPublicPort });
|
|
1744
|
+
startEdgeProbe(boundPublicPort, opts.host, logFd);
|
|
1745
|
+
|
|
1746
|
+
// Sanity check, not a port-move trigger: if the edge could not take the port
|
|
1747
|
+
// the adopted tunnel points at, that tunnel is now aimed at a dead origin and
|
|
1748
|
+
// would serve nothing. Drop it so the fresh-tunnel path below replaces it.
|
|
1749
|
+
// Unreachable in normal operation — reaching it means a zombie held the
|
|
1750
|
+
// public port, which is the one remaining way the public URL can rotate.
|
|
1751
|
+
if (tunnelAdopted && tunnelPort !== null && tunnelPort !== boundPublicPort) {
|
|
1752
|
+
log("WARN", `Adopted tunnel targets origin ${tunnelPort} but the edge bound ${boundPublicPort} — the public URL cannot be preserved, replacing the tunnel`);
|
|
1753
|
+
if (adoptedTunnelPid) { try { process.kill(adoptedTunnelPid, "SIGTERM"); } catch {} adoptedTunnelPid = null; }
|
|
1754
|
+
tunnelUrl = null;
|
|
1755
|
+
tunnelPort = null;
|
|
1756
|
+
updateStatus({ shareUrl: null, tunnelPid: null, tunnelPort: null });
|
|
1757
|
+
tunnelAdopted = false;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1573
1760
|
// Spawn server + (fresh) tunnel in parallel
|
|
1574
1761
|
const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];
|
|
1575
1762
|
if (opts.share && !tunnelAdopted) promises.push(spawnTunnel(_opts.port));
|