@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 +6 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/package.json +2 -2
- package/skills-lock.json +11 -0
- package/spike-memory-region-dump.ps1 +69 -0
- package/src/services/supervisor.ts +45 -24
- package/src/services/windows-process-tree.ts +24 -3
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.
|
|
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.
|
|
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.
|
|
4
|
-
"description": "Personal Project Manager
|
|
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",
|
package/skills-lock.json
ADDED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
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
|
/**
|