@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.
|
|
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.
|
|
258
|
+
<!-- Generated from src/server/routes/ for PPM v0.17.6 -->
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(() =>
|
|
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 {
|
|
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 {
|
|
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:
|
|
501
|
-
log("INFO", `Tunnel ready: ${tunnelUrl} (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
|
|
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},
|
|
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
|
|
1357
|
-
//
|
|
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
|
-
|
|
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
|
|