@hienlh/ppm 0.17.49 → 0.17.51

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.
@@ -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.49 at build time. Re-run `ppm export skill --install` to refresh. -->
74
+ <!-- Generated for PPM v0.17.51 at build time. Re-run `ppm export skill --install` to refresh. -->
@@ -288,4 +288,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
288
288
  - `ws://<host>/ws/terminal` — PTY terminal multiplexer
289
289
  - `ws://<host>/ws/extensions` — extension host channel
290
290
 
291
- <!-- Generated from src/server/routes/ for PPM v0.17.49 -->
291
+ <!-- Generated from src/server/routes/ for PPM v0.17.51 -->
@@ -153,3 +153,134 @@ the handle count low also keeps `fs.inotify.max_user_instances` (default 128) ou
153
153
 
154
154
  Raising the sysctl limit is not a fix — each watch pins ~1KB of kernel memory, so a 2M ceiling
155
155
  reserves ~2GB to paper over waste.
156
+
157
+ ---
158
+
159
+ ## Public tunnel URL stability
160
+
161
+ ### Why the public URL used to rotate on every upgrade
162
+
163
+ **Problem**: a quick trycloudflare tunnel dies with its `cloudflared` process and the URL cannot be
164
+ recovered. The supervisor kept that process alive across a self-replace upgrade, but a tunnel is
165
+ pinned to one *origin port*. When the server could not rebind that port it moved to a nearby one,
166
+ the supervisor re-pointed the tunnel, and the URL changed. The port moved because of a zombie port.
167
+
168
+ **Zombie port**: on Windows a child spawned with fd stdio inherits every inheritable handle,
169
+ including a listening socket. The server spawns chat/tool/MCP subprocesses constantly; when one is
170
+ orphaned it keeps the server's socket open, so the port stays in LISTEN under a dead PID and can
171
+ never be rebound. Three different debris shapes caused this within two weeks — `nohup` coreutils,
172
+ a `bun run dev:web` tree, and orphaned `mcp-remote` MCP-connector processes. Whitelisting each new
173
+ shape was a losing game.
174
+
175
+ **Fix**: the server no longer needs a stable port. A dedicated **edge forwarder** owns the public
176
+ port and pipes raw TCP to whatever loopback port the server happened to bind, so `cloudflared`
177
+ stays pinned to the edge forever.
178
+
179
+ What makes it work: **the edge spawns no child processes**, so its socket can never be inherited
180
+ and its port can never zombie. A test enforces that invariant (`supervisor-resilience.test.ts` →
181
+ "the edge forwarder never spawns a child process"). Any subprocess call added to
182
+ `src/services/edge-forwarder.ts` reintroduces the original bug.
183
+
184
+ **Files**: `src/services/edge-forwarder.ts`, `src/services/edge-target-resolver.ts`,
185
+ `src/services/supervisor.ts`
186
+
187
+ ### Bun drops socket data that arrives while a socket is paused
188
+
189
+ Forwarding means resolving the upstream first, so the client's first bytes usually arrive before
190
+ there is anywhere to send them. The obvious guard — `socket.pause()` until `.pipe()` is wired —
191
+ silently loses them on Bun 1.3.13. So does leaving the socket with no `data` listener. Both
192
+ variants swallowed the first HTTP request and the connection simply hung.
193
+
194
+ A standalone repro compared four variants (`pause`→`pipe`, `pause`→`pipe`→`resume`, no pause, and
195
+ buffering). Only buffering worked: attach a `data` listener synchronously on the connection tick,
196
+ buffer the chunks (bounded — an unbounded buffer is a memory DoS during an upgrade window), then
197
+ replay them into the upstream and pipe. Removing the listener and piping must happen in the same
198
+ tick so no chunk slips through the gap.
199
+
200
+ ### `_opts.port` in the supervisor means the PUBLIC port, not the server's
201
+
202
+ After the edge took over the public port, three call sites still read that value as the server's,
203
+ and none of them failed a test:
204
+
205
+ - the **server health probe** — a dead edge looks like a dead server, so the supervisor would kill
206
+ a healthy one every third cycle;
207
+ - the **pre-self-replace port wait**, which tree-kills whatever holds the port — that is the edge,
208
+ during an upgrade, which is exactly the URL rotation being fixed;
209
+ - the **stopped page**, which bound the public port directly and collided with the edge.
210
+
211
+ The health probe now reads the server's own `.server-port`, the self-replace wait is skipped when
212
+ an edge is running, and the stopped page binds loopback and publishes itself so the edge routes to
213
+ it. **When you change what a widely-read variable means, audit every reader — tests will not find
214
+ these.**
215
+
216
+ ### Adoption must come before any bind probe
217
+
218
+ `ensureBindablePort` treats a live PPM process holding the port as debris and tree-kills it, so
219
+ probing the public port before adopting the edge kills the healthy edge it was about to adopt.
220
+ Adopt first; probe only when there is nothing to adopt.
221
+
222
+ Related: `findPortListenerPid` needs `netstat` on Windows and `lsof` on POSIX, and returns `0` when
223
+ the tool is missing. Treating "cannot tell" as "does not match" refused every adoption on such a
224
+ box and spawned a duplicate edge that then could not bind.
225
+
226
+ ---
227
+
228
+ ## Process enumeration must not depend on `ps`
229
+
230
+ **Problem**: `collectProcessTree` and `isPpmProcess` shelled out to `ps`, which ships in `procps` —
231
+ a package slim Debian images leave out, including the one PPM's own suite runs in. Both functions
232
+ swallow the spawn error and return "no descendants" / "not a PPM process", so a missing binary
233
+ **silently disables orphan reaping** rather than failing loudly. Two tests had been timing out for
234
+ weeks and were written off as environmental.
235
+
236
+ **Fix**: read `/proc` directly on Linux (`/proc/<pid>/stat` for the pid→ppid map,
237
+ `/proc/<pid>/cmdline` for argv) and keep `ps` only as the macOS path. No subprocess, no hidden
238
+ dependency, and much faster — the two tests went from 5s timeouts to ~100ms.
239
+
240
+ Parsing note: `/proc/<pid>/stat` is `pid (comm) state ppid …` and `comm` may contain spaces **and
241
+ parentheses**, so anchor on the last `)` instead of splitting the line naively.
242
+
243
+ All call sites now go through `src/services/proc-table-linux.ts`, which reads the table once per
244
+ call and derives what each caller used to ask `ps` for:
245
+
246
+ | `ps` column | `/proc` source |
247
+ |---|---|
248
+ | `pid`, `ppid` | `/proc/<pid>/stat` fields 1 and 4 |
249
+ | `%cpu` | `(utime+stime)/HZ / elapsed` — fields 14, 15, 22 plus `/proc/uptime` |
250
+ | `rss` | field 24 (pages) × page size |
251
+ | `etimes` | `uptime − starttime/HZ` |
252
+ | `lstart` | `btime` from `/proc/stat` + `starttime/HZ` |
253
+ | `args` | `/proc/<pid>/cmdline` (NUL-separated) |
254
+ | `comm` | `/proc/<pid>/comm` |
255
+
256
+ `HZ` is assumed to be 100 — `sysconf(_SC_CLK_TCK)` is unreachable from JS, and every mainstream
257
+ Linux ships 100. A wrong value would skew a CPU percentage, nothing more.
258
+
259
+ ## Bun on Linux cannot re-watch a deleted-and-recreated directory
260
+
261
+ **Problem**: `WatchTree` releases a watcher when a directory disappears and re-covers it when it
262
+ comes back. On Bun 1.3.13 + Linux the new watcher is silent forever: Bun keys its `fs.watch`
263
+ registry by the literal path string and reuses the dead inotify watch. Closing the old handle
264
+ first, or waiting seconds before re-watching, makes no difference.
265
+
266
+ **Evidence** (`spike-bun-recursive-watch-probe.mjs`): plain recursive and non-recursive watches
267
+ both deliver; both go silent for a recreated directory. On **Windows** (bun 1.3.10) every case
268
+ delivers, so the defect is Linux-only.
269
+
270
+ **No clean workaround.** A trailing separator is a different key and works exactly once; `//` and
271
+ `///` normalise to the same key, so a rotating-spelling scheme fails from the second cycle.
272
+
273
+ **The poisoning does not spread**, and that is what made a fix affordable: a directory Bun has
274
+ never watched works normally even inside a recreated parent
275
+ (`spike-bun-watch-poison-scope-probe.mjs`). So only paths that were actually re-attached are dead.
276
+
277
+ **Fix**: `WatchTree` remembers every path it has handed to `fs.watch`. Re-attaching one of them
278
+ means that directory was deleted and recreated, so on Linux it hands the directory to
279
+ `RecreatedDirPoller` (readdir + mtime diff, 1s interval, hard cap of 64 directories) and covers the
280
+ subtree non-recursively so each child gets a watcher on a path the runtime still honours. Windows
281
+ and macOS never construct the poller.
282
+
283
+ `WatchTreeStats.polledDirs` reports how many directories are on the degraded path, and the cap
284
+ being hit sets `truncated` — so a churn storm shows up in stats instead of silently growing the
285
+ poll set. Given this watcher once reached ~360k inotify watches, refusing to grow without limit
286
+ matters more than perfect coverage.
@@ -2075,12 +2075,22 @@ The supervisor is a long-lived parent process that manages server + tunnel child
2075
2075
  **Architecture:**
2076
2076
  ```
2077
2077
  Supervisor Process (parent)
2078
- ├── Server Child (Hono HTTP server)
2079
- │ ├── Health checks every 30s (/api/health)
2078
+ ├── Edge Forwarder (detached; owns the PUBLIC port)
2079
+ │ ├── Raw TCP pipe server's loopback port (no HTTP parsing, so WS/SSE pass through)
2080
+ │ ├── Target read per connection from ~/.ppm/.server-port
2081
+ │ ├── SPAWNS NO CHILDREN — that is why its socket can never be inherited
2082
+ │ │ and its port can never zombie; cloudflared stays pinned to it forever
2083
+ │ ├── Liveness probe every 10s → respawn
2084
+ │ └── Adopted by PID across a self-replace upgrade (adopt BEFORE any bind probe)
2085
+
2086
+ ├── Server Child (Hono HTTP server, 127.0.0.1:0 — OS-assigned)
2087
+ │ ├── Publishes its bound port to ~/.ppm/.server-port (single writer)
2088
+ │ ├── Health checks every 30s against that port, never the public one
2080
2089
  │ ├── Auto-restart on crash (exponential backoff, max 10 restarts)
2081
2090
  │ └── If in "stopped" state, serves minimal 503 page instead of restarting
2082
2091
 
2083
2092
  ├── Tunnel Child (Cloudflare Quick Tunnel, always enabled)
2093
+ │ ├── Origin is the EDGE port, so a server port move cannot rotate the URL
2084
2094
  │ ├── URL probe every 2min
2085
2095
  │ ├── Auto-reconnect on failure
2086
2096
  │ └── URL persisted to status.json
@@ -2095,7 +2105,8 @@ Supervisor Process (parent)
2095
2105
  │ └── npm registry poll → availableVersion written to status.json
2096
2106
 
2097
2107
  ├── Stopped Page Server
2098
- │ ├── Lightweight HTTP handler on same port as server
2108
+ │ ├── Lightweight HTTP handler on a loopback port, published to .server-port
2109
+ │ │ so the edge routes the public URL to it (it stands in for the server)
2099
2110
  │ ├── Returns 503 on /api/health
2100
2111
  │ └── Tunnels Cloud WS calls through to PPM Cloud
2101
2112
 
package/package.json CHANGED
@@ -1,106 +1,106 @@
1
- {
2
- "name": "@hienlh/ppm",
3
- "version": "0.17.49",
4
- "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
- "author": "hienlh",
6
- "license": "SEE LICENSE IN LICENSE",
7
- "module": "src/index.ts",
8
- "type": "module",
9
- "workspaces": [
10
- "packages/*"
11
- ],
12
- "bin": {
13
- "ppm": "src/index.ts"
14
- },
15
- "scripts": {
16
- "dev": "concurrently \"bun run dev:server\" \"bun run dev:web\"",
17
- "dev:server": "bun run --hot src/server/index.ts __serve__ 8081 0.0.0.0 dev",
18
- "dev:web": "bun run vite --config vite.config.ts",
19
- "tunnel": "cloudflared tunnel --url http://localhost:5173",
20
- "dev:tunnel": "concurrently \"bun run dev:server\" \"bun run dev:web\" \"bun run tunnel\"",
21
- "build:web": "bun run vite build --config vite.config.ts",
22
- "build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
23
- "start": "bun run src/index.ts start",
24
- "typecheck": "bunx tsc --noEmit",
25
- "test:docker": "docker compose -f docker-compose.test.yml up --build --renew-anon-volumes --abort-on-container-exit --exit-code-from test",
26
- "generate:bot": "bun scripts/generate-bot-coordinator.ts --update",
27
- "generate:skill": "bun scripts/generate-ppm-skill.ts",
28
- "prepublishOnly": "bun run generate:skill && bun run build:web"
29
- },
30
- "devDependencies": {
31
- "@tailwindcss/vite": "^4.2.1",
32
- "@types/archiver": "^7.0.0",
33
- "@types/bun": "latest",
34
- "@types/js-yaml": "^4.0.9",
35
- "@types/node": "^25.5.0",
36
- "@types/react": "^19.2.14",
37
- "@types/react-dom": "^19.2.3",
38
- "@vitejs/plugin-react": "^6.0.1",
39
- "concurrently": "^9.2.1",
40
- "esbuild": "^0.27.4",
41
- "tailwindcss": "^4.2.1",
42
- "vite": "^8.0.0",
43
- "vite-plugin-pwa": "^1.2.0",
44
- "workbox-precaching": "^7.4.0"
45
- },
46
- "peerDependencies": {
47
- "typescript": "^5.9.3"
48
- },
49
- "dependencies": {
50
- "@anthropic-ai/claude-agent-sdk": "0.3.251",
51
- "@codemirror/lang-sql": "^6.10.0",
52
- "@glideapps/glide-data-grid": "^6.0.3",
53
- "@inquirer/prompts": "^8.3.0",
54
- "@monaco-editor/react": "^4.7.0",
55
- "@radix-ui/react-switch": "^1.2.6",
56
- "@skitee3000/bun-pty": "^0.3.3",
57
- "@tanstack/react-table": "^8.21.3",
58
- "@tanstack/react-virtual": "^3.14.5",
59
- "@types/diff": "^8.0.0",
60
- "@uiw/react-codemirror": "^4.25.8",
61
- "@use-gesture/react": "^10.3.1",
62
- "@xterm/addon-fit": "0.12.0-beta.285",
63
- "@xterm/addon-web-links": "0.13.0-beta.285",
64
- "@xterm/addon-webgl": "0.20.0-beta.284",
65
- "@xterm/xterm": "6.1.0-beta.285",
66
- "archiver": "^7.0.1",
67
- "class-variance-authority": "^0.7.1",
68
- "clsx": "^2.1.1",
69
- "commander": "^14.0.3",
70
- "croner": "^9",
71
- "diff": "^9.0.0",
72
- "diff2html": "^3.4.56",
73
- "highlight.js": "^11.11.1",
74
- "hono": "^4.12.8",
75
- "ignore": "^7.0.5",
76
- "js-yaml": "^4.1.1",
77
- "katex": "^0.16.45",
78
- "lucide-react": "^0.577.0",
79
- "mammoth": "^1.12.0",
80
- "mermaid": "^11.13.0",
81
- "monaco-editor": "0.55.1",
82
- "next-themes": "^0.4.6",
83
- "postgres": "^3.4.8",
84
- "qrcode-terminal": "^0.12.0",
85
- "qrcode.react": "^4.2.0",
86
- "radix-ui": "^1.4.3",
87
- "react": "^19.2.4",
88
- "react-dom": "^19.2.4",
89
- "react-markdown": "^10.1.0",
90
- "react-resizable-panels": "^4.7.3",
91
- "rehype-highlight": "^7.0.2",
92
- "rehype-katex": "^7.0.1",
93
- "rehype-raw": "^7.0.0",
94
- "remark-breaks": "^4.0.0",
95
- "remark-gfm": "^4.0.1",
96
- "remark-math": "^6.0.0",
97
- "shiki": "^4.3.1",
98
- "simple-git": "^3.33.0",
99
- "sonner": "^2.0.7",
100
- "tailwind-merge": "^3.5.0",
101
- "unzipit": "^2.0.3",
102
- "use-stick-to-bottom": "^1.1.6",
103
- "vite-plugin-monaco-editor": "^1.1.0",
104
- "zustand": "^5.0.11"
105
- }
106
- }
1
+ {
2
+ "name": "@hienlh/ppm",
3
+ "version": "0.17.51",
4
+ "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
+ "author": "hienlh",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "module": "src/index.ts",
8
+ "type": "module",
9
+ "workspaces": [
10
+ "packages/*"
11
+ ],
12
+ "bin": {
13
+ "ppm": "src/index.ts"
14
+ },
15
+ "scripts": {
16
+ "dev": "concurrently \"bun run dev:server\" \"bun run dev:web\"",
17
+ "dev:server": "bun run --hot src/server/index.ts __serve__ 8081 0.0.0.0 dev",
18
+ "dev:web": "bun run vite --config vite.config.ts",
19
+ "tunnel": "cloudflared tunnel --url http://localhost:5173",
20
+ "dev:tunnel": "concurrently \"bun run dev:server\" \"bun run dev:web\" \"bun run tunnel\"",
21
+ "build:web": "bun run vite build --config vite.config.ts",
22
+ "build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
23
+ "start": "bun run src/index.ts start",
24
+ "typecheck": "bunx tsc --noEmit",
25
+ "test:docker": "docker compose -f docker-compose.test.yml up --build --renew-anon-volumes --abort-on-container-exit --exit-code-from test",
26
+ "generate:bot": "bun scripts/generate-bot-coordinator.ts --update",
27
+ "generate:skill": "bun scripts/generate-ppm-skill.ts",
28
+ "prepublishOnly": "bun run generate:skill && bun run build:web"
29
+ },
30
+ "devDependencies": {
31
+ "@tailwindcss/vite": "^4.2.1",
32
+ "@types/archiver": "^7.0.0",
33
+ "@types/bun": "latest",
34
+ "@types/js-yaml": "^4.0.9",
35
+ "@types/node": "^25.5.0",
36
+ "@types/react": "^19.2.14",
37
+ "@types/react-dom": "^19.2.3",
38
+ "@vitejs/plugin-react": "^6.0.1",
39
+ "concurrently": "^9.2.1",
40
+ "esbuild": "^0.27.4",
41
+ "tailwindcss": "^4.2.1",
42
+ "vite": "^8.0.0",
43
+ "vite-plugin-pwa": "^1.2.0",
44
+ "workbox-precaching": "^7.4.0"
45
+ },
46
+ "peerDependencies": {
47
+ "typescript": "^5.9.3"
48
+ },
49
+ "dependencies": {
50
+ "@anthropic-ai/claude-agent-sdk": "0.3.251",
51
+ "@codemirror/lang-sql": "^6.10.0",
52
+ "@glideapps/glide-data-grid": "^6.0.3",
53
+ "@inquirer/prompts": "^8.3.0",
54
+ "@monaco-editor/react": "^4.7.0",
55
+ "@radix-ui/react-switch": "^1.2.6",
56
+ "@skitee3000/bun-pty": "^0.3.3",
57
+ "@tanstack/react-table": "^8.21.3",
58
+ "@tanstack/react-virtual": "^3.14.5",
59
+ "@types/diff": "^8.0.0",
60
+ "@uiw/react-codemirror": "^4.25.8",
61
+ "@use-gesture/react": "^10.3.1",
62
+ "@xterm/addon-fit": "0.12.0-beta.285",
63
+ "@xterm/addon-web-links": "0.13.0-beta.285",
64
+ "@xterm/addon-webgl": "0.20.0-beta.284",
65
+ "@xterm/xterm": "6.1.0-beta.285",
66
+ "archiver": "^7.0.1",
67
+ "class-variance-authority": "^0.7.1",
68
+ "clsx": "^2.1.1",
69
+ "commander": "^14.0.3",
70
+ "croner": "^9",
71
+ "diff": "^9.0.0",
72
+ "diff2html": "^3.4.56",
73
+ "highlight.js": "^11.11.1",
74
+ "hono": "^4.12.8",
75
+ "ignore": "^7.0.5",
76
+ "js-yaml": "^4.1.1",
77
+ "katex": "^0.16.45",
78
+ "lucide-react": "^0.577.0",
79
+ "mammoth": "^1.12.0",
80
+ "mermaid": "^11.13.0",
81
+ "monaco-editor": "0.55.1",
82
+ "next-themes": "^0.4.6",
83
+ "postgres": "^3.4.8",
84
+ "qrcode-terminal": "^0.12.0",
85
+ "qrcode.react": "^4.2.0",
86
+ "radix-ui": "^1.4.3",
87
+ "react": "^19.2.4",
88
+ "react-dom": "^19.2.4",
89
+ "react-markdown": "^10.1.0",
90
+ "react-resizable-panels": "^4.7.3",
91
+ "rehype-highlight": "^7.0.2",
92
+ "rehype-katex": "^7.0.1",
93
+ "rehype-raw": "^7.0.0",
94
+ "remark-breaks": "^4.0.0",
95
+ "remark-gfm": "^4.0.1",
96
+ "remark-math": "^6.0.0",
97
+ "shiki": "^4.3.1",
98
+ "simple-git": "^3.33.0",
99
+ "sonner": "^2.0.7",
100
+ "tailwind-merge": "^3.5.0",
101
+ "unzipit": "^2.0.3",
102
+ "use-stick-to-bottom": "^1.1.6",
103
+ "vite-plugin-monaco-editor": "^1.1.0",
104
+ "zustand": "^5.0.11"
105
+ }
106
+ }
@@ -177,6 +177,12 @@ export async function restartServer(options: { force?: boolean }) {
177
177
  // terminal (and its process group) to receive SIGHUP.
178
178
  const params = JSON.stringify({
179
179
  serverPid, port, host, serverScript,
180
+ // The server's own loopback port, distinct from `port` (the PUBLIC port,
181
+ // owned by the edge forwarder). The worker must only ever reclaim this one:
182
+ // force-killing whatever listens on the public port would kill the edge,
183
+ // which is detached and legitimately still running even with no supervisor.
184
+ serverPort: (status.serverPort as number | undefined) ?? null,
185
+ serverPortFile: resolve(getPpmDir(), ".server-port"),
180
186
  statusFile: statusFile(),
181
187
  pidFile: pidFile(),
182
188
  restartingFlag: restartingFlag(),
@@ -210,16 +216,20 @@ async function main() {
210
216
  try { process.kill(P.serverPid); log("INFO", "Restart: killed old server PID " + P.serverPid); } catch {}
211
217
  await Bun.sleep(500);
212
218
 
213
- // Force-kill any process still holding the port (handles orphan/zombie processes)
219
+ // Force-kill anything still holding the OLD SERVER's loopback port (orphaned
220
+ // grandchildren keep its inherited socket open). Never P.port: that is the
221
+ // public port and the edge forwarder is listening there — it is detached, so
222
+ // it is alive and correct even when no supervisor is.
214
223
  const killByPort = () => {
224
+ if (!P.serverPort) return;
215
225
  try {
216
226
  if (process.platform === "win32") {
217
- const r = Bun.spawnSync(["cmd", "/c", "netstat -ano | findstr :" + P.port + " | findstr LISTENING"]);
227
+ const r = Bun.spawnSync(["cmd", "/c", "netstat -ano | findstr :" + P.serverPort + " | findstr LISTENING"]);
218
228
  const lines = r.stdout.toString().trim().split("\\n");
219
229
  const pids = new Set(lines.map((l: string) => l.trim().split(/\\s+/).pop()).filter(Boolean));
220
230
  for (const pid of pids) { try { process.kill(Number(pid)); } catch {} }
221
231
  } else {
222
- const r = Bun.spawnSync(["lsof", "-t", "-i", ":" + P.port]);
232
+ const r = Bun.spawnSync(["lsof", "-t", "-i", ":" + P.serverPort]);
223
233
  const pids = r.stdout.toString().trim().split("\\n").filter(Boolean);
224
234
  for (const pid of pids) { try { process.kill(Number(pid)); } catch {} }
225
235
  }
@@ -227,28 +237,21 @@ async function main() {
227
237
  };
228
238
  killByPort();
229
239
 
230
- // Wait for port to be free (up to 5s)
231
- const start = Date.now();
232
- while (Date.now() - start < 5000) {
233
- const inUse: boolean = await new Promise((res) => {
234
- const t = createServer()
235
- .once("error", () => res(true))
236
- .once("listening", () => { t.close(() => res(false)); })
237
- .listen(P.port, P.host);
238
- });
239
- if (!inUse) break;
240
- killByPort();
241
- await Bun.sleep(200);
242
- }
240
+ // No wait-for-free loop: the replacement server asks for port 0, so a wedged
241
+ // old port cannot block it. That loop existed only because the server used to
242
+ // need one specific port back.
243
+ try { unlinkSync(P.serverPortFile); } catch {}
243
244
 
244
245
  // Spawn new server — on Windows use PowerShell Start-Process for true detach
245
246
  // (Bun.spawn + unref on Windows keeps child in same job object → dies when worker exits)
246
247
  let childPid: number;
247
248
  // Compiled binary: execPath IS the server, no "run script" needed
248
249
  const isCompiled = !process.execPath.includes("bun");
250
+ // Port 0 / loopback, matching how the supervisor spawns it: the edge owns the
251
+ // public port and forwards to whatever the server ends up binding.
249
252
  const serverArgs = isCompiled
250
- ? ["__serve__", String(P.port), P.host]
251
- : ["run", P.serverScript, "__serve__", String(P.port), P.host];
253
+ ? ["__serve__", "0", "127.0.0.1"]
254
+ : ["run", P.serverScript, "__serve__", "0", "127.0.0.1"];
252
255
 
253
256
  if (process.platform === "win32") {
254
257
  const bunExe = process.execPath.replace(/\\\\/g, "\\\\\\\\");
@@ -293,16 +296,29 @@ async function main() {
293
296
  // Remove restarting flag
294
297
  try { unlinkSync(P.restartingFlag); } catch {}
295
298
 
296
- // Health check (up to 10s)
299
+ // Health check (up to 10s). Probe the PUBLIC port first — that is the whole
300
+ // chain (edge → server) and what a user actually hits. Fall back to the
301
+ // server's own loopback port so a dead edge is reported as "server up, public
302
+ // access down" instead of a blanket restart failure.
297
303
  let ready = false;
304
+ let edgeUp = false;
305
+ const probe = async (p: number) => {
306
+ try {
307
+ const res = await fetch("http://127.0.0.1:" + p + "/api/health", { signal: AbortSignal.timeout(1000) });
308
+ return res.ok;
309
+ } catch { return false; }
310
+ };
298
311
  const hStart = Date.now();
299
312
  while (Date.now() - hStart < 10000) {
300
- try {
301
- const res = await fetch("http://127.0.0.1:" + P.port + "/api/health", { signal: AbortSignal.timeout(1000) });
302
- if (res.ok) { ready = true; break; }
303
- } catch {}
313
+ if (await probe(P.port)) { ready = true; edgeUp = true; break; }
314
+ let direct = 0;
315
+ try { direct = parseInt(readFileSync(P.serverPortFile, "utf-8").trim(), 10); } catch {}
316
+ if (direct > 0 && await probe(direct)) { ready = true; break; }
304
317
  await Bun.sleep(300);
305
318
  }
319
+ if (ready && !edgeUp) {
320
+ log("WARN", "Server is up but the public port is not being served — edge forwarder is down. Run 'ppm stop --kill' then 'ppm start'.");
321
+ }
306
322
 
307
323
  // Check tunnel
308
324
  let tunnelAlive = false;
@@ -81,6 +81,11 @@ export async function stopServer(options?: { all?: boolean; kill?: boolean }) {
81
81
  if (data.supervisorPid) { killPid(data.supervisorPid, "supervisor"); killed++; }
82
82
  if (data.pid) { killPid(data.pid, "server"); killed++; }
83
83
  if (data.tunnelPid) { killPid(data.tunnelPid, "tunnel"); killed++; }
84
+ // The edge forwarder is spawned detached so it survives an upgrade's
85
+ // self-replace — which means killing the supervisor does NOT take it
86
+ // down. Left running it keeps holding the public port and the next
87
+ // `ppm start` collides with it.
88
+ if (data.edgePid) { killPid(data.edgePid, "edge"); killed++; }
84
89
  } catch {}
85
90
  }
86
91
  if (existsSync(pidFile())) {
package/src/index.ts CHANGED
@@ -179,9 +179,12 @@ export async function buildProgram(): Promise<Command> {
179
179
  * which would reject them as unknown commands. Source installs spawn the
180
180
  * `.ts` files directly and never reach this entry with a sentinel.
181
181
  */
182
- export function resolveEntryMode(argv: string[]): "supervise" | "serve" | "cli" {
182
+ export function resolveEntryMode(
183
+ argv: string[],
184
+ ): "supervise" | "serve" | "edge" | "cli" {
183
185
  if (argv.includes("__supervise__")) return "supervise";
184
186
  if (argv.includes("__serve__")) return "serve";
187
+ if (argv.includes("__edge__")) return "edge";
185
188
  return "cli";
186
189
  }
187
190
 
@@ -193,6 +196,9 @@ if (import.meta.main) {
193
196
  case "serve":
194
197
  await import("./server/index.ts");
195
198
  break;
199
+ case "edge":
200
+ await import("./services/edge-forwarder.ts");
201
+ break;
196
202
  default: {
197
203
  const program = await buildProgram();
198
204
  program.parse();
@@ -1,5 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
+ import { writeFileSync } from "node:fs";
4
+ import { SERVER_PORT_FILE } from "../services/edge-target-resolver.ts";
3
5
  import { configService } from "../services/config.service.ts";
4
6
  import { VERSION } from "../version.ts";
5
7
  import { authMiddleware } from "./middleware/auth.ts";
@@ -940,5 +942,16 @@ if (process.argv.includes("__serve__")) {
940
942
  }, 200);
941
943
  }
942
944
 
943
- console.log(`Server child ready on port ${port}`);
945
+ // Publish the port we actually bound. With `port: 0` the OS picks it, so this
946
+ // file is the only way anything else can find the server — the edge forwarder
947
+ // reads it to know where to send traffic, and the supervisor mirrors it into
948
+ // status.json. The server is the single writer; see edge-target-resolver.ts
949
+ // for why this is not status.json.
950
+ try {
951
+ writeFileSync(SERVER_PORT_FILE(), String(server.port));
952
+ } catch (e) {
953
+ console.error(`[serve] Failed to publish server port: ${e}`);
954
+ }
955
+
956
+ console.log(`Server child ready on port ${server.port}`);
944
957
  }