@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.
@@ -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
- // Guarantee a bindable port before spawning. If the preferred port is held by
323
- // a zombie socket (common after hibernate/resume), fall back to a free port
324
- // and re-point the tunnel at the new origin — otherwise the child would
325
- // crash-loop on EADDRINUSE until max_restarts and the supervisor would pause.
326
- // Prefer the port a LIVE tunnel already targets (may differ from the
327
- // configured port after an earlier zombie-port fallback): binding anywhere
328
- // else forces a tunnel restart, which rotates the public trycloudflare URL.
329
- const tunnelAlive = !!(tunnelUrl || tunnelChild || adoptedTunnelPid);
330
- const preferred = tunnelAlive && tunnelPort !== null ? tunnelPort : _opts.port;
331
- if (preferred !== _opts.port) {
332
- log("INFO", `Preferring tunnel origin port ${preferred} over configured ${_opts.port} (public URL continuity)`);
333
- }
334
- const boundPort = await ensureBindablePort(preferred, _opts.host);
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
- // _opts.port tracks the server's real port (spawnServer updates it on
676
- // zombie-port fallback). status.json can override, but never trust a
677
- // startup-time closure value a stale port here makes the health check
678
- // kill a healthy server every cycle.
679
- let checkPort = _opts.port;
680
- try {
681
- const status = readStatus();
682
- if (status.port && typeof status.port === "number") checkPort = status.port;
683
- } catch {}
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
- const portFreeStart = Date.now();
926
- const portTimeout = process.platform === "win32" ? 3_000 : 10_000;
927
- while (Date.now() - portFreeStart < portTimeout) {
928
- const inUse = !(await isPortBindable(_opts.port, _opts.host));
929
- if (!inUse) break;
930
- log("DEBUG", `Port ${_opts.port} still in use, waiting...`);
931
- await Bun.sleep(200);
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
- // Windows: the tracked-descendant snapshot can miss an orphan (an SDK
935
- // grandchild spawned after the last snapshot, or whose parent chain already
936
- // broke). If it still holds the inherited listening socket, the new
937
- // supervisor can never bind. Resolve the real holder via netstat and
938
- // tree-kill it so the handoff doesn't dead-end on a zombie port.
939
- if (process.platform === "win32") {
940
- const stillInUse = !(await isPortBindable(_opts.port, _opts.host));
941
- if (stillInUse) {
942
- const holderPid = findPortListenerPid(_opts.port);
943
- if (holderPid > 0) {
944
- log("WARN", `Port ${_opts.port} still held by PID ${holderPid} before self-replace — tree-killing`);
945
- killProcessTree(holderPid);
946
- await Bun.sleep(500);
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
- startStoppedPage(_opts.port, _opts.host);
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__", String(opts.port), opts.host,
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));