@hienlh/ppm 0.17.49 → 0.17.50
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 +3668 -3662
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/docs/lessons-learned.md +69 -0
- package/docs/system-architecture.md +14 -3
- package/package.json +106 -106
- package/src/cli/commands/restart.ts +39 -23
- package/src/cli/commands/stop.ts +5 -0
- package/src/index.ts +7 -1
- package/src/server/index.ts +14 -1
- package/src/services/supervisor-stopped-page.ts +20 -3
- package/src/services/supervisor.ts +248 -61
|
@@ -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.50 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.
|
|
291
|
+
<!-- Generated from src/server/routes/ for PPM v0.17.50 -->
|
package/docs/lessons-learned.md
CHANGED
|
@@ -153,3 +153,72 @@ 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.
|
|
@@ -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
|
-
├──
|
|
2079
|
-
│ ├──
|
|
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
|
|
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.
|
|
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.50",
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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__",
|
|
251
|
-
: ["run", P.serverScript, "__serve__",
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
package/src/cli/commands/stop.ts
CHANGED
|
@@ -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(
|
|
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();
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Minimal HTTP server that serves a "stopped" page when the PPM server child is down.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* It stands in for the server, so it binds an OS-assigned loopback port and
|
|
5
|
+
* publishes it to `.server-port` exactly as the real server does. The edge
|
|
6
|
+
* forwarder then routes the public port here and the tunnel URL keeps working —
|
|
7
|
+
* which is the whole point of the page. Binding the public port directly would
|
|
8
|
+
* collide with the edge, which owns it.
|
|
4
9
|
*/
|
|
5
|
-
import { appendFileSync } from "node:fs";
|
|
10
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
6
11
|
import { resolve } from "node:path";
|
|
7
12
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
13
|
+
import { SERVER_PORT_FILE } from "./edge-target-resolver.ts";
|
|
8
14
|
|
|
9
15
|
function log(level: string, msg: string) {
|
|
10
16
|
const ts = new Date().toISOString();
|
|
@@ -36,6 +42,11 @@ const STOPPED_HTML = `<!DOCTYPE html>
|
|
|
36
42
|
|
|
37
43
|
let stoppedServer: ReturnType<typeof Bun.serve> | null = null;
|
|
38
44
|
|
|
45
|
+
/**
|
|
46
|
+
* @param port 0 in normal operation — the edge finds this page via
|
|
47
|
+
* `.server-port`, so it needs no fixed port of its own.
|
|
48
|
+
* @param host loopback; the edge is the only public listener.
|
|
49
|
+
*/
|
|
39
50
|
export function startStoppedPage(port: number, host: string) {
|
|
40
51
|
if (stoppedServer) return;
|
|
41
52
|
|
|
@@ -56,7 +67,13 @@ export function startStoppedPage(port: number, host: string) {
|
|
|
56
67
|
});
|
|
57
68
|
},
|
|
58
69
|
});
|
|
59
|
-
|
|
70
|
+
// Take over as the edge's target so the public URL shows this page.
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(SERVER_PORT_FILE(), String(stoppedServer.port));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
log("WARN", `Failed to publish stopped-page port: ${e}`);
|
|
75
|
+
}
|
|
76
|
+
log("INFO", `Stopped page serving on ${host}:${stoppedServer.port}`);
|
|
60
77
|
} catch (e) {
|
|
61
78
|
log("WARN", `Failed to start stopped page: ${e}`);
|
|
62
79
|
}
|