@hienlh/ppm 0.17.5 → 0.17.6

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.6] - 2026-07-23
4
+
5
+ ### Fixed
6
+ - **Tunnel no longer leaks orphaned cloudflared processes on regeneration** — `restartTunnel()` nulls the mutable global `tunnelChild` before spawning a fresh loop, but a concurrent `spawnTunnel` loop still awaited `tunnelChild.exited` on that global. When it was nulled mid-await the loop threw `TypeError: null is not an object (evaluating 'tunnelChild.exited')` and died **without reaping the cloudflared child it spawned** — leaving an orphaned process (PID 1) that retried forever. Over weeks dozens accumulated (observed 62 such crashes in one deployment). `spawnTunnel` now owns its child via a local reference for the whole loop and only touches the global while it still points at that child, so a regeneration can never crash the previous loop or orphan its process.
7
+ - **Tunnel regeneration no longer trips Cloudflare's per-IP rate limit** — the resume-from-sleep detector regenerated the quick tunnel on *every* wall-clock gap >90s. A laptop that sleeps/wakes frequently rotated the tunnel hundreds of times/day (observed ~325 in 3 weeks), each spawning a new `cloudflared` → trycloudflare rate-limited the source IP and **no new tunnel could register** (`control stream encountered a failure while serving`, retrying forever). Regeneration is now throttled to at most once per 5 minutes; the existing tunnel probe still heals a genuinely-zombied URL, and cloudflared self-heals transient QUIC drops on its own.
8
+
3
9
  ## [0.17.5] - 2026-07-16
4
10
 
5
11
  ### Fixed
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
71
71
  - Third-party extensions (inspect via `ppm ext list`).
72
72
  - The Claude Agent SDK internals (separate skill).
73
73
 
74
- <!-- Generated for PPM v0.17.5 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.17.6 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -255,4 +255,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
255
255
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
256
256
  - `ws://<host>/ws/extensions` — extension host channel
257
257
 
258
- <!-- Generated from src/server/routes/ for PPM v0.17.5 -->
258
+ <!-- Generated from src/server/routes/ for PPM v0.17.6 -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.17.5",
3
+ "version": "0.17.6",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -56,6 +56,13 @@ let shuttingDown = false;
56
56
  // respawning, so restarts never leave two concurrent spawnTunnel loops racing
57
57
  // (the old leak that spawned dozens of orphaned cloudflared processes).
58
58
  let tunnelGeneration = 0;
59
+ // Throttle tunnel regeneration. A quick-tunnel URL rotation spawns a NEW
60
+ // cloudflared → trycloudflare rate-limits quick tunnels per source IP, so a
61
+ // sleep/wake storm that regenerates hundreds of times gets the whole IP
62
+ // throttled and NO new tunnel can register ("control stream encountered a
63
+ // failure while serving"). Never rotate more than once per this window.
64
+ let lastTunnelRegenAt = 0;
65
+ const TUNNEL_REGEN_MIN_INTERVAL_MS = 300_000; // 5min
59
66
 
60
67
  // Module-level refs for softStop (needs access to respawn args)
61
68
  let _serverArgs: string[] = [];
@@ -167,6 +174,7 @@ async function ensureBindablePort(preferred: number, host: string): Promise<numb
167
174
  * is dead. Fire-and-forget: spawnTunnel's own loop owns liveness afterwards.
168
175
  */
169
176
  function restartTunnel(port: number) {
177
+ lastTunnelRegenAt = Date.now();
170
178
  if (tunnelChild) { try { tunnelChild.kill(); } catch {} tunnelChild = null; }
171
179
  if (adoptedTunnelPid) { try { process.kill(adoptedTunnelPid, "SIGTERM"); } catch {} adoptedTunnelPid = null; }
172
180
  tunnelUrl = null;
@@ -457,8 +465,16 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
457
465
  ]
458
466
  : [bin, "tunnel", "--url", `http://127.0.0.1:${port}`];
459
467
 
468
+ // Own this cloudflared via a LOCAL ref for the whole loop. `tunnelChild` is a
469
+ // mutable global that a concurrent restartTunnel() nulls/reassigns; awaiting
470
+ // `tunnelChild.exited` on it would throw `TypeError: null is not an object`
471
+ // mid-flight, killing this loop WITHOUT reaping the child we spawned →
472
+ // orphaned cloudflared (PID 1). The local ref keeps our lifecycle
473
+ // self-contained; we only touch the global when it still points at us.
474
+ let child: Subprocess;
460
475
  try {
461
- tunnelChild = Bun.spawn(tunnelCmd, { stderr: tunnelLogFd, stdout: "ignore", stdin: "ignore" });
476
+ child = Bun.spawn(tunnelCmd, { stderr: tunnelLogFd, stdout: "ignore", stdin: "ignore" });
477
+ tunnelChild = child; // publish so restartTunnel/killStaleTunnel can reach the live child
462
478
  } finally {
463
479
  // Close our handle; cloudflared keeps its own via dup2
464
480
  try { closeSync(tunnelLogFd); } catch {}
@@ -466,12 +482,12 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
466
482
  if (underSystemd) log("INFO", "Tunnel spawned inside transient systemd-run scope (escapes ppm.service cgroup)");
467
483
 
468
484
  try {
469
- tunnelUrl = await extractUrlFromLogFile(() => tunnelChild?.exitCode ?? null);
485
+ tunnelUrl = await extractUrlFromLogFile(() => child.exitCode);
470
486
  } catch (err) {
471
487
  log("ERROR", `Tunnel URL extraction failed: ${err}`);
472
488
  tunnelUrl = null;
473
- try { tunnelChild.kill(); } catch {}
474
- tunnelChild = null;
489
+ try { child.kill(); } catch {}
490
+ if (tunnelChild === child) tunnelChild = null;
475
491
 
476
492
  if (shuttingDown) return;
477
493
 
@@ -492,19 +508,19 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
492
508
  // A newer authoritative (re)start superseded us while we extracted the URL —
493
509
  // kill our child so it can't linger as an orphan, and bail.
494
510
  if (generation !== tunnelGeneration) {
495
- try { tunnelChild.kill(); } catch {}
496
- tunnelChild = null;
511
+ try { child.kill(); } catch {}
512
+ if (tunnelChild === child) tunnelChild = null;
497
513
  return;
498
514
  }
499
515
 
500
- updateStatus({ shareUrl: tunnelUrl, tunnelPid: tunnelChild.pid, tunnelPort: port });
501
- log("INFO", `Tunnel ready: ${tunnelUrl} (PID: ${tunnelChild.pid})`);
516
+ updateStatus({ shareUrl: tunnelUrl, tunnelPid: child.pid, tunnelPort: port });
517
+ log("INFO", `Tunnel ready: ${tunnelUrl} (PID: ${child.pid})`);
502
518
 
503
519
  // One-time sync of tunnel URL to cloud (WS handles periodic heartbeat)
504
520
  await syncUrlToCloud(tunnelUrl);
505
521
 
506
- const exitCode = await tunnelChild.exited;
507
- tunnelChild = null;
522
+ const exitCode = await child.exited;
523
+ if (tunnelChild === child) tunnelChild = null;
508
524
  const deadUrl = tunnelUrl;
509
525
  tunnelUrl = null;
510
526
 
@@ -527,7 +543,7 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
527
543
  // Never give up: cap the counter so backoff plateaus at BACKOFF_MAX_MS (no 10-min dark window).
528
544
  if (tunnelRestarts > MAX_RESTARTS) tunnelRestarts = MAX_RESTARTS;
529
545
  const delay = backoffDelay(tunnelRestarts) + Math.floor(Math.random() * 1000);
530
- log("WARN", `Tunnel process exited (code=${exitCode}, signal=${tunnelChild === null ? "killed" : "self"}, url=${deadUrl}), restart in ${delay}ms (#${tunnelRestarts})`);
546
+ log("WARN", `Tunnel process exited (code=${exitCode}, url=${deadUrl}), restart in ${delay}ms (#${tunnelRestarts})`);
531
547
  await Bun.sleep(delay);
532
548
 
533
549
  if (generation !== tunnelGeneration) return; // superseded during backoff
@@ -1353,10 +1369,19 @@ export async function runSupervisor(opts: {
1353
1369
  tunnelFailCount = 0;
1354
1370
  // If paused solely from the post-resume failure cascade, resume the server.
1355
1371
  if (getState() === "paused") triggerResume();
1356
- // The old quick-tunnel session is almost certainly dead force a fresh one
1357
- // at the server's live port (the URL rotates anyway, so follow the server).
1372
+ // The old quick-tunnel session MAY be dead after resume, but cloudflared
1373
+ // self-heals transient QUIC drops on its own. Only force a fresh tunnel if
1374
+ // we haven't just rotated — otherwise a laptop that sleeps/wakes constantly
1375
+ // regenerates hundreds of quick tunnels/day, tripping trycloudflare's
1376
+ // per-IP rate limit so NO tunnel can register. The tunnel probe still
1377
+ // regenerates a genuinely-zombied URL (edge dropped) within ~5min.
1358
1378
  if (getState() === "running" && (tunnelUrl || tunnelChild || adoptedTunnelPid)) {
1359
- restartTunnel(_opts.port);
1379
+ const sinceRegen = now - lastTunnelRegenAt;
1380
+ if (sinceRegen >= TUNNEL_REGEN_MIN_INTERVAL_MS) {
1381
+ restartTunnel(_opts.port);
1382
+ } else {
1383
+ log("INFO", `Resume: tunnel regen skipped (last regen ${Math.round(sinceRegen / 1000)}s ago); probe will heal if truly dead`);
1384
+ }
1360
1385
  }
1361
1386
  }, RESUME_TICK_MS);
1362
1387