@hienlh/ppm 0.17.28 → 0.17.29

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.29] - 2026-08-25
4
+
5
+ ### Fixed
6
+ - **PPM said it had started but nothing answered** — `ppm start` printed the local address and a share link, yet neither localhost nor the link ever responded, and the status file kept insisting everything was running while every process it referred to was already dead. Two things went wrong in sequence, both on Windows. First, before starting the server, PPM briefly listens on the port itself to make sure it is free; starting the share tunnel at the same moment made Windows hand the tunnel a copy of that short-lived listening socket — a copy the tunnel never closes — so the port stayed occupied and the real server could never take it, timing out after ten seconds on every attempt. Second, the supervisor then went looking for whichever process was hogging the port so it could clear it out, and the answer came back as the supervisor itself — which it dutifully killed, taking the server, the tunnel and its own status reporting down with it. That is why the status file still said "running": nothing was left alive to correct it. The port check and the starting of child processes now take turns instead of overlapping, so the socket can no longer leak into a child; and if the port ever does show up as held by the supervisor itself, it restarts the tunnel — the only thing that can actually be holding the leaked copy — rather than killing itself.
7
+ - **PPM's reserved memory could grow without limit on Windows** — the supervisor takes a periodic inventory of the helper processes the server has started, so that after a crash it knows exactly what to clean up. Each inventory launches a PowerShell and builds a list of every process on the machine, which is fine on its own — but on a busy machine one inventory could still be running when the next fired, and every simultaneous run permanently reserved a chunk of memory that was never returned to the system. Left long enough this ratcheted up to an observed 19GB of reserved memory against 1GB actually in use. Inventories now run one at a time — a tick that finds the previous one still running simply skips its turn — and a PowerShell that gets stuck is cut off after twenty seconds instead of lingering forever.
8
+
3
9
  ## [0.17.28] - 2026-08-21
4
10
 
5
11
  ### Added
@@ -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.28 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.17.29 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -282,4 +282,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
282
282
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
283
283
  - `ws://<host>/ws/extensions` — extension host channel
284
284
 
285
- <!-- Generated from src/server/routes/ for PPM v0.17.28 -->
285
+ <!-- Generated from src/server/routes/ for PPM v0.17.29 -->
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.17.28",
4
- "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
3
+ "version": "0.17.29",
4
+ "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
7
  "module": "src/index.ts",
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "game-assets": {
5
+ "source": "Meowa-AI/meowa-skills",
6
+ "sourceType": "github",
7
+ "skillPath": "skills/game-assets/SKILL.md",
8
+ "computedHash": "b3ceb77a09475144f2a598da451ec57a4f4e3994711b62a8eeae60a25fd2d916"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,69 @@
1
+ # Enumerate committed memory regions of a process via VirtualQueryEx.
2
+ # Reveals allocation shape: many small regions (per-iteration leak) vs few huge (allocator arena).
3
+ param([int]$ProcessId)
4
+
5
+ Add-Type -TypeDefinition @"
6
+ using System;
7
+ using System.Runtime.InteropServices;
8
+ public class MemProbe {
9
+ [StructLayout(LayoutKind.Sequential)]
10
+ public struct MEMORY_BASIC_INFORMATION {
11
+ public IntPtr BaseAddress;
12
+ public IntPtr AllocationBase;
13
+ public uint AllocationProtect;
14
+ public uint Alignment1;
15
+ public IntPtr RegionSize;
16
+ public uint State;
17
+ public uint Protect;
18
+ public uint Type;
19
+ public uint Alignment2;
20
+ }
21
+ [DllImport("kernel32.dll", SetLastError=true)]
22
+ public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
23
+ [DllImport("kernel32.dll", SetLastError=true)]
24
+ public static extern int VirtualQueryEx(IntPtr h, IntPtr addr, out MEMORY_BASIC_INFORMATION mbi, int len);
25
+ [DllImport("kernel32.dll")]
26
+ public static extern bool CloseHandle(IntPtr h);
27
+ }
28
+ "@
29
+
30
+ $h = [MemProbe]::OpenProcess(0x0400 -bor 0x0010, $false, $ProcessId)
31
+ if ($h -eq [IntPtr]::Zero) { Write-Error "OpenProcess failed: $([ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message)"; exit 1 }
32
+
33
+ $size = [Runtime.InteropServices.Marshal]::SizeOf([type][MemProbe+MEMORY_BASIC_INFORMATION])
34
+ $addr = [IntPtr]::Zero
35
+ $regions = New-Object System.Collections.ArrayList
36
+ $max = [Int64]0x7FFFFFFFFFFF
37
+
38
+ while ([Int64]$addr -lt $max) {
39
+ $mbi = New-Object MemProbe+MEMORY_BASIC_INFORMATION
40
+ if ([MemProbe]::VirtualQueryEx($h, $addr, [ref]$mbi, $size) -eq 0) { break }
41
+ $rs = [Int64]$mbi.RegionSize
42
+ if ($rs -le 0) { break }
43
+ # MEM_COMMIT=0x1000, MEM_PRIVATE=0x20000
44
+ if ($mbi.State -eq 0x1000 -and $mbi.Type -eq 0x20000) {
45
+ [void]$regions.Add([pscustomobject]@{ Base=$mbi.BaseAddress; Size=$rs; Protect=$mbi.Protect })
46
+ }
47
+ $addr = [IntPtr]([Int64]$addr + $rs)
48
+ }
49
+ [void][MemProbe]::CloseHandle($h)
50
+
51
+ $total = ($regions | Measure-Object -Property Size -Sum).Sum
52
+ "PID $ProcessId : committed-private regions = $($regions.Count), total = $([math]::Round($total/1GB,2)) GB"
53
+ ""
54
+ "--- by protection flag ---"
55
+ $regions | Group-Object Protect | Sort-Object { ($_.Group | Measure-Object Size -Sum).Sum } -Descending |
56
+ Select-Object @{n='Protect';e={'0x{0:X}' -f [int]$_.Name}}, Count,
57
+ @{n='TotalGB';e={[math]::Round((($_.Group|Measure-Object Size -Sum).Sum)/1GB,2)}} |
58
+ Format-Table -AutoSize
59
+
60
+ "--- by region size bucket ---"
61
+ $regions | Group-Object { [math]::Pow(2,[math]::Floor([math]::Log($_.Size,2))) } |
62
+ Sort-Object { ($_.Group | Measure-Object Size -Sum).Sum } -Descending |
63
+ Select-Object -First 12 @{n='BucketMB';e={[math]::Round([double]$_.Name/1MB,3)}}, Count,
64
+ @{n='TotalGB';e={[math]::Round((($_.Group|Measure-Object Size -Sum).Sum)/1GB,2)}} |
65
+ Format-Table -AutoSize
66
+
67
+ "--- top 10 single regions ---"
68
+ $regions | Sort-Object Size -Descending | Select-Object -First 10 @{n='Base';e={'0x{0:X}' -f [Int64]$_.Base}},
69
+ @{n='MB';e={[math]::Round($_.Size/1MB,1)}}, @{n='Protect';e={'0x{0:X}' -f [int]$_.Protect}} | Format-Table -AutoSize
@@ -109,6 +109,22 @@ function backoffDelay(restartCount: number): number {
109
109
  return Math.min(BACKOFF_BASE_MS * 2 ** (restartCount - 1), BACKOFF_MAX_MS);
110
110
  }
111
111
 
112
+ // ─── Probe/spawn gate ──────────────────────────────────────────────────
113
+ // On Windows, spawning a child with fd stdio turns on handle inheritance for
114
+ // the whole process, so EVERY inheritable handle — including a port-probe
115
+ // listener that happens to be open at that instant — is duplicated into the
116
+ // child. The child (cloudflared, server) never closes the copy, the kernel
117
+ // keeps the port in LISTEN, and the server child can never bind: startup
118
+ // crash-loops with "port still in use" while netstat blames the supervisor.
119
+ // Serialize all bind probes and all child spawns through this gate so a probe
120
+ // socket is never open across a CreateProcess. Never nest gated calls.
121
+ let probeSpawnGate: Promise<unknown> = Promise.resolve();
122
+ function withProbeSpawnGate<T>(fn: () => Promise<T> | T): Promise<T> {
123
+ const run = probeSpawnGate.then(fn);
124
+ probeSpawnGate = run.catch(() => {});
125
+ return run;
126
+ }
127
+
112
128
  // ─── Port recovery ─────────────────────────────────────────────────────
113
129
  // The probe socket really listens, so clients retrying against the port (browser
114
130
  // tabs, extension/chat WebSockets after a server restart) can connect to it. An
@@ -116,7 +132,9 @@ function backoffDelay(restartCount: number): number {
116
132
  // before it ever spawned — supervisor alive, tunnel alive, nothing serving.
117
133
  // Hence: drop incoming connections, and never let the probe outlive the timeout.
118
134
  function isPortBindable(port: number, host: string): Promise<boolean> {
119
- return new Promise((resolve) => {
135
+ // Gated: the probe listener must never be open while a child is spawned,
136
+ // or the child inherits the socket handle and wedges the port (see gate).
137
+ return withProbeSpawnGate(() => new Promise<boolean>((resolve) => {
120
138
  const net = require("node:net") as typeof import("node:net");
121
139
  let settled = false;
122
140
  const finish = (bindable: boolean) => {
@@ -135,7 +153,7 @@ function isPortBindable(port: number, host: string): Promise<boolean> {
135
153
  tester.once("error", () => finish(false));
136
154
  tester.once("listening", () => tester.close(() => finish(true)));
137
155
  tester.listen(port, host);
138
- });
156
+ }));
139
157
  }
140
158
 
141
159
  /**
@@ -157,7 +175,17 @@ async function ensureBindablePort(preferred: number, host: string): Promise<numb
157
175
  if (holderPid > 0) {
158
176
  let alive = false;
159
177
  try { process.kill(holderPid, 0); alive = true; } catch {}
160
- if (alive && isPpmProcess(holderPid)) {
178
+ if (holderPid === process.pid) {
179
+ // The LISTEN entry is our own leaked probe socket, kept open by a
180
+ // child that inherited the handle at spawn time. killProcessTree here
181
+ // would kill THIS supervisor (and everything under it) — the exact
182
+ // "starts then dies, status.json says running" failure. Bounce the
183
+ // tunnel instead: cloudflared is the detached child holding the
184
+ // inherited handle, and replacing it releases the port.
185
+ log("WARN", `Port ${preferred} LISTEN owned by this supervisor — leaked probe handle inherited by a child; bouncing tunnel to release it`);
186
+ restartTunnel(preferred);
187
+ await Bun.sleep(1500);
188
+ } else if (alive && isPpmProcess(holderPid)) {
161
189
  log("WARN", `Port ${preferred} held by stale PPM process (PID ${holderPid}) — reclaiming`);
162
190
  killProcessTree(holderPid);
163
191
  await Bun.sleep(800);
@@ -278,7 +306,8 @@ export async function spawnServer(
278
306
  ? [process.execPath, ...serverArgs]
279
307
  : [process.execPath, "run", resolve(import.meta.dir, "..", "server", "index.ts"), ...serverArgs];
280
308
 
281
- serverChild = Bun.spawn({
309
+ // Gated: never spawn while a port probe is open (see gate).
310
+ serverChild = await withProbeSpawnGate(() => Bun.spawn({
282
311
  cmd,
283
312
  stdio: ["ignore", logFd, logFd],
284
313
  env: process.env,
@@ -286,7 +315,7 @@ export async function spawnServer(
286
315
  // supervisor is spawned consoleless (detached), so without this its console
287
316
  // children — and the Claude SDK grandchildren they spawn — pop blank windows.
288
317
  windowsHide: true,
289
- });
318
+ }));
290
319
 
291
320
  const childPid = serverChild.pid;
292
321
  updateStatus({ pid: childPid });
@@ -427,11 +456,13 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
427
456
  // when the supervisor itself was started consoleless by the upgrade path.
428
457
  if (process.platform === "win32") {
429
458
  const { spawn: nodeSpawn } = require("node:child_process") as typeof import("node:child_process");
430
- const proc = nodeSpawn(bin, quickArgs, {
459
+ // Gated: fd stdio enables handle inheritance — spawning while a port
460
+ // probe is open would hand cloudflared the listener handle (see gate).
461
+ const proc = await withProbeSpawnGate(() => nodeSpawn(bin, quickArgs, {
431
462
  detached: true,
432
463
  windowsHide: true,
433
464
  stdio: ["ignore", "ignore", tunnelLogFd] as ["ignore", "ignore", number],
434
- });
465
+ }));
435
466
  proc.unref();
436
467
  try { closeSync(tunnelLogFd); } catch {} // child keeps its own fd
437
468
  const pid = proc.pid ?? null;
@@ -497,7 +528,8 @@ export async function spawnTunnel(port: number, generation: number = ++tunnelGen
497
528
  // self-contained; we only touch the global when it still points at us.
498
529
  let child: Subprocess;
499
530
  try {
500
- child = Bun.spawn(tunnelCmd, { stderr: tunnelLogFd, stdout: "ignore", stdin: "ignore" });
531
+ child = await withProbeSpawnGate(() =>
532
+ Bun.spawn(tunnelCmd, { stderr: tunnelLogFd, stdout: "ignore", stdin: "ignore" }));
501
533
  tunnelChild = child; // publish so restartTunnel/killStaleTunnel can reach the live child
502
534
  } finally {
503
535
  // Close our handle; cloudflared keeps its own via dup2
@@ -840,13 +872,7 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
840
872
  const portFreeStart = Date.now();
841
873
  const portTimeout = process.platform === "win32" ? 3_000 : 10_000;
842
874
  while (Date.now() - portFreeStart < portTimeout) {
843
- const inUse = await new Promise<boolean>((resolve) => {
844
- const net = require("node:net") as typeof import("node:net");
845
- const tester = net.createServer()
846
- .once("error", (e: NodeJS.ErrnoException) => resolve(e.code === "EADDRINUSE"))
847
- .once("listening", () => tester.close(() => resolve(false)))
848
- .listen(_opts.port, _opts.host);
849
- });
875
+ const inUse = !(await isPortBindable(_opts.port, _opts.host));
850
876
  if (!inUse) break;
851
877
  log("DEBUG", `Port ${_opts.port} still in use, waiting...`);
852
878
  await Bun.sleep(200);
@@ -858,13 +884,7 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
858
884
  // supervisor can never bind. Resolve the real holder via netstat and
859
885
  // tree-kill it so the handoff doesn't dead-end on a zombie port.
860
886
  if (process.platform === "win32") {
861
- const stillInUse = await new Promise<boolean>((resolve) => {
862
- const net = require("node:net") as typeof import("node:net");
863
- const tester = net.createServer()
864
- .once("error", (e: NodeJS.ErrnoException) => resolve(e.code === "EADDRINUSE"))
865
- .once("listening", () => tester.close(() => resolve(false)))
866
- .listen(_opts.port, _opts.host);
867
- });
887
+ const stillInUse = !(await isPortBindable(_opts.port, _opts.host));
868
888
  if (stillInUse) {
869
889
  const holderPid = findPortListenerPid(_opts.port);
870
890
  if (holderPid > 0) {
@@ -884,12 +904,13 @@ async function selfReplace(): Promise<{ success: boolean; error?: string }> {
884
904
  // launchd/systemd tears the replacement down along with our process group
885
905
  // the moment we exit — it dies seconds after adopting the tunnel.
886
906
  const { spawn: nodeSpawn } = require("node:child_process") as typeof import("node:child_process");
887
- const proc = nodeSpawn(cmd[0]!, cmd.slice(1), {
907
+ // Gated: never spawn while a port probe is open (see gate).
908
+ const proc = await withProbeSpawnGate(() => nodeSpawn(cmd[0]!, cmd.slice(1), {
888
909
  detached: true,
889
910
  stdio: ["ignore", newLogFd, newLogFd] as any,
890
911
  env: process.env as NodeJS.ProcessEnv,
891
912
  windowsHide: true,
892
- });
913
+ }));
893
914
  const killNewChild = () => { try { if (proc.pid) process.kill(proc.pid); } catch {} };
894
915
  proc.unref();
895
916
  try { closeSync(newLogFd); } catch {} // child inherited fd, parent can close
@@ -79,14 +79,24 @@ const trackedFile = () => resolve(getPpmDir(), "tracked-descendants.json");
79
79
  const PS_LIST_CMD =
80
80
  'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId)|$($_.ParentProcessId)|$($_.CreationDate.Ticks)" }';
81
81
 
82
+ // Shorter than the caller's 30s poll, so a wedged PowerShell can never outlive
83
+ // the interval that spawned it and stack up behind the in-flight guard.
84
+ const LIST_TIMEOUT_MS = 20_000;
85
+
82
86
  async function listProcesses(): Promise<Map<number, { ppid: number; ticks: string }>> {
83
87
  const map = new Map<number, { ppid: number; ticks: string }>();
84
88
  const proc = Bun.spawn(
85
89
  ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", PS_LIST_CMD],
86
90
  { stdout: "pipe", stderr: "ignore", stdin: "ignore", windowsHide: true },
87
91
  );
88
- const out = await new Response(proc.stdout).text();
89
- await proc.exited;
92
+ const killTimer = setTimeout(() => { try { proc.kill(); } catch {} }, LIST_TIMEOUT_MS);
93
+ let out: string;
94
+ try {
95
+ out = await new Response(proc.stdout).text();
96
+ await proc.exited;
97
+ } finally {
98
+ clearTimeout(killTimer);
99
+ }
90
100
  for (const line of out.split("\n")) {
91
101
  const [pidStr, ppidStr, ticks] = line.trim().split("|");
92
102
  const pid = parseInt(pidStr ?? "", 10);
@@ -136,8 +146,17 @@ export function killProcessTree(pid: number): void {
136
146
  * matching creation time) are kept even if they have since orphaned out of
137
147
  * the tree — that is exactly the case the reaper exists for.
138
148
  */
149
+ let snapshotInFlight = false;
150
+
139
151
  export async function snapshotServerDescendants(rootPid: number): Promise<void> {
140
152
  if (process.platform !== "win32") return;
153
+ // The supervisor fires this from a timer without awaiting. Every concurrent
154
+ // call spawns a PowerShell and builds a full process map, which costs a 32MiB
155
+ // allocator segment that is never returned to the OS — so overlapping calls
156
+ // ratchet the process commit charge up permanently (observed: 19GB committed
157
+ // against 1GB resident). One at a time; a skipped tick is harmless.
158
+ if (snapshotInFlight) return;
159
+ snapshotInFlight = true;
141
160
  try {
142
161
  const procs = await listProcesses();
143
162
 
@@ -166,7 +185,9 @@ export async function snapshotServerDescendants(rootPid: number): Promise<void>
166
185
  }
167
186
 
168
187
  writeFileSync(trackedFile(), JSON.stringify([...tracked.values()]));
169
- } catch {}
188
+ } catch {} finally {
189
+ snapshotInFlight = false;
190
+ }
170
191
  }
171
192
 
172
193
  /**