@hienlh/ppm 0.23.4 → 0.23.5
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/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/package.json +1 -1
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
- package/src/providers/codex-app-server/codex-history.ts +43 -5
- package/src/providers/codex-app-server/codex-provider.ts +11 -5
- package/src/server/ws/chat.ts +22 -3
- package/src/services/edge-forwarder.ts +191 -191
- package/src/services/windows-process-tree.ts +351 -351
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.5] - 2026-09-27
|
|
4
|
+
|
|
5
|
+
### Improved
|
|
6
|
+
- New Codex chats reply about 2.5 s sooner, and starting one no longer freezes the whole server for a couple of seconds.
|
|
7
|
+
- The Codex session list loads about 4x faster.
|
|
8
|
+
|
|
3
9
|
## [0.23.4] - 2026-09-27
|
|
4
10
|
|
|
5
11
|
### Improved
|
|
@@ -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.23.
|
|
74
|
+
<!-- Generated for PPM v0.23.5 at build time. Re-run `ppm export skill --install` to refresh. -->
|
|
@@ -781,7 +781,7 @@ Show recent runs for a schedule
|
|
|
781
781
|
Manage and inspect discovered skills & commands
|
|
782
782
|
|
|
783
783
|
**Options:**
|
|
784
|
-
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm
|
|
784
|
+
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
|
|
785
785
|
|
|
786
786
|
**Usage:** `ppm skills [options] [command]`
|
|
787
787
|
|
|
@@ -791,7 +791,7 @@ List all discovered skills and commands
|
|
|
791
791
|
|
|
792
792
|
**Options:**
|
|
793
793
|
- `--json` — JSON output
|
|
794
|
-
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm
|
|
794
|
+
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
|
|
795
795
|
|
|
796
796
|
### `ppm skills search`
|
|
797
797
|
|
|
@@ -799,7 +799,7 @@ Fuzzy search skills and commands
|
|
|
799
799
|
|
|
800
800
|
**Options:**
|
|
801
801
|
- `--json` — JSON output
|
|
802
|
-
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm
|
|
802
|
+
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
|
|
803
803
|
|
|
804
804
|
**Usage:** `ppm skills search [options] <query>`
|
|
805
805
|
|
|
@@ -809,7 +809,7 @@ Show detailed info for a specific skill
|
|
|
809
809
|
|
|
810
810
|
**Options:**
|
|
811
811
|
- `--json` — JSON output
|
|
812
|
-
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm
|
|
812
|
+
- `--project <path>` — Project path (default: `"C:\\Users\\PC\\ppm"`)
|
|
813
813
|
|
|
814
814
|
**Usage:** `ppm skills info [options] <name>`
|
|
815
815
|
|
|
@@ -359,4 +359,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
|
|
|
359
359
|
- `ws://<host>/ws/terminal` — PTY terminal multiplexer
|
|
360
360
|
- `ws://<host>/ws/extensions` — extension host channel
|
|
361
361
|
|
|
362
|
-
<!-- Generated from src/server/routes/ for PPM v0.23.
|
|
362
|
+
<!-- Generated from src/server/routes/ for PPM v0.23.5 -->
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ChatMessage, ChatEvent, SessionInfo } from "../provider.interface.ts";
|
|
2
2
|
import { stripSharedContext } from "../../shared/provider-context.ts";
|
|
3
|
-
import { readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
|
|
3
|
+
import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync, statSync, realpathSync } from "node:fs";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { getPpmDir } from "../../services/ppm-dir.ts";
|
|
@@ -221,11 +221,46 @@ export function parseRolloutJsonl(
|
|
|
221
221
|
return messages;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
/**
|
|
225
|
-
|
|
224
|
+
/** First read of a rollout's head: `session_meta` carries the base instructions, measured at 22–27 KB. */
|
|
225
|
+
const HEADER_FIRST_READ_BYTES = 32 * 1024;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read a rollout file's header (session_meta, plus its title when asked) from as little of
|
|
229
|
+
* the file as answers it.
|
|
230
|
+
*
|
|
231
|
+
* Reading whole files here is what froze the server: identifying one rollout among thousands
|
|
232
|
+
* read every transcript in full (1.8 GB, over a second of synchronous I/O) whenever no file
|
|
233
|
+
* name matched. The head is read in growing slices instead, and a slice's unterminated last
|
|
234
|
+
* line — possibly a split multi-byte character — is dropped by `completeLines`, so a partial
|
|
235
|
+
* read never parses a partial record.
|
|
236
|
+
*/
|
|
237
|
+
function readSessionMeta(
|
|
238
|
+
file: string,
|
|
239
|
+
opts?: { withTitle?: boolean; titleIf?: (header: RolloutHeader) => boolean },
|
|
240
|
+
): RolloutHeader | null {
|
|
241
|
+
let fd: number | undefined;
|
|
226
242
|
try {
|
|
227
|
-
|
|
243
|
+
fd = openSync(file, "r");
|
|
244
|
+
const size = fstatSync(fd).size;
|
|
245
|
+
let buffer = Buffer.alloc(Math.min(size, HEADER_FIRST_READ_BYTES));
|
|
246
|
+
let filled = 0;
|
|
247
|
+
while (true) {
|
|
248
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, filled);
|
|
249
|
+
filled += read;
|
|
250
|
+
// A file that shrank under us reads short; stop rather than wait for bytes that are gone.
|
|
251
|
+
const eof = filled >= size || read === 0;
|
|
252
|
+
const header = readRolloutHeader(buffer.toString("utf-8", 0, filled), opts);
|
|
253
|
+
// A header the caller is about to discard needs no title: most rollouts in a shared
|
|
254
|
+
// tree belong to other projects, and a title can sit megabytes in, or nowhere at all.
|
|
255
|
+
const wanted = header && (!opts?.titleIf || opts.titleIf(header));
|
|
256
|
+
const complete = header && (!opts?.withTitle || !wanted || header.title || header.parentThreadId);
|
|
257
|
+
if (complete || eof) return header;
|
|
258
|
+
const grown = Buffer.alloc(Math.min(size, buffer.length * 4));
|
|
259
|
+
buffer.copy(grown, 0, 0, filled);
|
|
260
|
+
buffer = grown;
|
|
261
|
+
}
|
|
228
262
|
} catch { /* unreadable file → excluded (fail-closed) */ }
|
|
263
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
229
264
|
return null;
|
|
230
265
|
}
|
|
231
266
|
|
|
@@ -270,7 +305,10 @@ export function listCodexRollouts(
|
|
|
270
305
|
const sessions: SessionInfo[] = [];
|
|
271
306
|
|
|
272
307
|
for (const file of files) {
|
|
273
|
-
const meta = readSessionMeta(file, {
|
|
308
|
+
const meta = readSessionMeta(file, {
|
|
309
|
+
withTitle: true,
|
|
310
|
+
titleIf: (h) => !!h.cwd && !h.parentThreadId && normPath(h.cwd) === target,
|
|
311
|
+
});
|
|
274
312
|
if (!meta?.cwd) continue; // fail-closed: no cwd → exclude
|
|
275
313
|
if (normPath(meta.cwd) !== target) continue;
|
|
276
314
|
if (meta.parentThreadId) continue; // one step of another session, not a session
|
|
@@ -887,8 +887,12 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
887
887
|
|
|
888
888
|
// Only resume a rollout attributable to this project. An unknown/resumed ID
|
|
889
889
|
// without one must not silently become a fresh thread with a different identity.
|
|
890
|
-
|
|
891
|
-
|
|
890
|
+
// A session created here and never started has no rollout: codex names the thread with an
|
|
891
|
+
// id of its own, so no file can match this one. Looking anyway reads the head of every
|
|
892
|
+
// rollout on disk, synchronously, before the first turn can start.
|
|
893
|
+
const unstarted = this.unstartedSessions.has(sessionId);
|
|
894
|
+
const found = unstarted ? null : locateRollout(sessionId, cwd);
|
|
895
|
+
if (!found && !unstarted) throw missingRolloutError(sessionId);
|
|
892
896
|
|
|
893
897
|
const client = new CodexJsonRpcClient();
|
|
894
898
|
const channel = createEventChannel();
|
|
@@ -948,11 +952,13 @@ export class CodexAppServerProvider implements AIProvider {
|
|
|
948
952
|
if (account) setSessionCodexAccount(threadId, account.id);
|
|
949
953
|
}
|
|
950
954
|
// Snapshot persisted history so live message ids continue the rollout-N
|
|
951
|
-
// numbering (empty for a brand-new thread; full prior transcript on resume).
|
|
952
|
-
|
|
955
|
+
// numbering (empty for a brand-new thread; full prior transcript on resume). A new thread
|
|
956
|
+
// has nothing to snapshot, and its rollout may not be on disk yet — searching for it would
|
|
957
|
+
// fall back to reading the head of every other rollout.
|
|
958
|
+
live.history = found ? fromCodexSessionsDirs(threadId, (d) => {
|
|
953
959
|
const msgs = getRolloutMessages(d, threadId, cwd);
|
|
954
960
|
return msgs.length > 0 ? msgs : null;
|
|
955
|
-
}) ?? [];
|
|
961
|
+
}) ?? [] : [];
|
|
956
962
|
return live;
|
|
957
963
|
}
|
|
958
964
|
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -131,6 +131,12 @@ interface SessionEntry {
|
|
|
131
131
|
idleSince?: number;
|
|
132
132
|
/** When the last turn completed — the moment this session's prompt cache was last written */
|
|
133
133
|
lastTurnEndedAt?: number;
|
|
134
|
+
/**
|
|
135
|
+
* When the message that opened the current turn reached the server — what "time to first
|
|
136
|
+
* event" is measured from. The consumer loop outlives turns, so its own clock would count
|
|
137
|
+
* the idle gap before a follow-up as waiting on the provider.
|
|
138
|
+
*/
|
|
139
|
+
turnRequestedAt?: { at: number; cold: boolean };
|
|
134
140
|
/** Pending release of the subprocess once its prompt cache lapses */
|
|
135
141
|
cacheReleaseTimer?: ReturnType<typeof setTimeout>;
|
|
136
142
|
}
|
|
@@ -716,9 +722,13 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
716
722
|
const isMetadataEvent = evType === "account_info" || evType === "account_retry" || evType === "streaming_status" || evType === "status_update";
|
|
717
723
|
if (!firstEventReceived && !isMetadataEvent) {
|
|
718
724
|
firstEventReceived = true;
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
725
|
+
const requested = entry.turnRequestedAt;
|
|
726
|
+
entry.turnRequestedAt = undefined;
|
|
727
|
+
// A turn nobody sent over this socket (scheduler, remote trigger) has no receipt time.
|
|
728
|
+
const waitMs = Date.now() - (requested?.at ?? startTime);
|
|
729
|
+
const path = requested ? (requested.cold ? "cold" : "warm") : "unsent";
|
|
730
|
+
console.log(`[chat] session=${sessionId} first SDK event after ${waitMs}ms: type=${evType} path=${path}`);
|
|
731
|
+
logSessionEvent(sessionId, "PERF", `First SDK event after ${waitMs}ms (type=${evType}, ${path})`);
|
|
722
732
|
if (heartbeat) clearInterval(heartbeat);
|
|
723
733
|
const newPhase = evType === "thinking" ? "thinking" : "streaming";
|
|
724
734
|
setPhase(sessionId, newPhase);
|
|
@@ -1197,6 +1207,8 @@ export const chatWebSocket = {
|
|
|
1197
1207
|
}
|
|
1198
1208
|
|
|
1199
1209
|
if (parsed.type === "message") {
|
|
1210
|
+
// Taken before any awaited work below (slash rewrites, resume), which is part of the wait.
|
|
1211
|
+
const messageReceivedAt = Date.now();
|
|
1200
1212
|
// Images count as content: a message may carry only a picture, with nothing typed.
|
|
1201
1213
|
const hasInlineImages = Array.isArray((parsed as { images?: unknown }).images)
|
|
1202
1214
|
&& ((parsed as { images: unknown[] }).images.length > 0);
|
|
@@ -1320,6 +1332,10 @@ export const chatWebSocket = {
|
|
|
1320
1332
|
|
|
1321
1333
|
// Store user message for reconnect replay (turn_events includes only assistant events)
|
|
1322
1334
|
entry.currentUserMessage = parsed.content;
|
|
1335
|
+
// Only a message that opens a turn is timed; one typed mid-turn joins the running turn.
|
|
1336
|
+
if (!entry.isStreamingActive || entry.phase === "idle") {
|
|
1337
|
+
entry.turnRequestedAt = { at: messageReceivedAt, cold: !entry.isStreamingActive };
|
|
1338
|
+
}
|
|
1323
1339
|
|
|
1324
1340
|
if (!entry.isStreamingActive) {
|
|
1325
1341
|
// First message or post-crash recovery: start persistent consumer
|
|
@@ -1467,6 +1483,9 @@ export const chatWebSocket = {
|
|
|
1467
1483
|
broadcastBackgroundRegistry(sessionId);
|
|
1468
1484
|
const provider = providerRegistry.get(providerId);
|
|
1469
1485
|
const instruction = `Call the KillShell tool with task_id "${shellId}" to stop that background command, then reply with just "Stopped.".`;
|
|
1486
|
+
if (!entry.isStreamingActive || entry.phase === "idle") {
|
|
1487
|
+
entry.turnRequestedAt = { at: Date.now(), cold: !entry.isStreamingActive };
|
|
1488
|
+
}
|
|
1470
1489
|
if (!entry.isStreamingActive) {
|
|
1471
1490
|
if (provider && "resumeSession" in provider) await (provider as any).resumeSession(sessionId);
|
|
1472
1491
|
if (entry.projectPath && provider && "ensureProjectPath" in provider) {
|
|
@@ -1,191 +1,191 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Edge forwarder — owns the public port and pipes raw TCP to the server's
|
|
3
|
-
* current loopback port.
|
|
4
|
-
*
|
|
5
|
-
* ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
|
|
6
|
-
*
|
|
7
|
-
* On Windows a child spawned with fd stdio inherits every inheritable handle,
|
|
8
|
-
* including a listening socket. When such a descendant is orphaned it keeps the
|
|
9
|
-
* socket open, so the port stays in LISTEN under a dead PID and can never be
|
|
10
|
-
* rebound — a "zombie port". The server hits this constantly because it spawns
|
|
11
|
-
* chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
|
|
12
|
-
*
|
|
13
|
-
* The edge exists precisely because it spawns nothing, so its socket cannot be
|
|
14
|
-
* inherited and its port cannot zombie. cloudflared can therefore stay pinned
|
|
15
|
-
* to it forever and the public URL stops rotating. Adding any child-process
|
|
16
|
-
* call to this file reintroduces the exact bug the edge was built to remove.
|
|
17
|
-
*
|
|
18
|
-
* Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
|
|
19
|
-
* untouched, and forwarded requests keep the headers cloudflared set.
|
|
20
|
-
*/
|
|
21
|
-
import net from "node:net";
|
|
22
|
-
import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
|
|
23
|
-
|
|
24
|
-
/** Delay between attempts while waiting for the server to come back. */
|
|
25
|
-
const CONNECT_RETRY_MS = 250;
|
|
26
|
-
/**
|
|
27
|
-
* How long a client connection waits for a reachable server before giving up.
|
|
28
|
-
* During an upgrade the server is briefly absent; refusing instantly would
|
|
29
|
-
* surface as a broken page rather than a slow one.
|
|
30
|
-
*/
|
|
31
|
-
const CONNECT_WINDOW_MS = 5000;
|
|
32
|
-
|
|
33
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
-
|
|
35
|
-
/** Resolve once the socket is connected; reject on the first connect error. */
|
|
36
|
-
function connectUpstream(port: number): Promise<net.Socket> {
|
|
37
|
-
return new Promise((resolvePromise, reject) => {
|
|
38
|
-
const upstream = net.connect({
|
|
39
|
-
port,
|
|
40
|
-
host: "127.0.0.1", // never taken from status.json — loopback only
|
|
41
|
-
allowHalfOpen: true,
|
|
42
|
-
});
|
|
43
|
-
const onError = (err: Error) => {
|
|
44
|
-
upstream.destroy();
|
|
45
|
-
reject(err);
|
|
46
|
-
};
|
|
47
|
-
upstream.once("error", onError);
|
|
48
|
-
upstream.once("connect", () => {
|
|
49
|
-
upstream.removeListener("error", onError);
|
|
50
|
-
resolvePromise(upstream);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Largest amount of client data held while waiting for the server. Bounds the
|
|
57
|
-
* memory a slow upload can pin during an upgrade window.
|
|
58
|
-
*/
|
|
59
|
-
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Pipe one client connection to the server.
|
|
63
|
-
*
|
|
64
|
-
* Resolving the upstream is async, so the first bytes usually arrive before
|
|
65
|
-
* there is anywhere to send them. They must be captured by a `data` listener
|
|
66
|
-
* attached synchronously on this very tick and replayed once the upstream
|
|
67
|
-
* exists.
|
|
68
|
-
*
|
|
69
|
-
* `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
|
|
70
|
-
* that arrive while paused are lost, and a later `pipe()`/`resume()` does not
|
|
71
|
-
* bring them back. A socket with no `data` listener drops them outright too.
|
|
72
|
-
* Both variants silently swallowed the first request in testing.
|
|
73
|
-
*/
|
|
74
|
-
async function handleConnection(
|
|
75
|
-
client: net.Socket,
|
|
76
|
-
connectWindowMs: number,
|
|
77
|
-
): Promise<void> {
|
|
78
|
-
const pending: Buffer[] = [];
|
|
79
|
-
let pendingBytes = 0;
|
|
80
|
-
let clientEnded = false;
|
|
81
|
-
const onEarlyData = (chunk: Buffer) => {
|
|
82
|
-
pendingBytes += chunk.length;
|
|
83
|
-
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
84
|
-
client.destroy();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
pending.push(chunk);
|
|
88
|
-
};
|
|
89
|
-
client.on("data", onEarlyData);
|
|
90
|
-
client.once("end", () => { clientEnded = true; });
|
|
91
|
-
|
|
92
|
-
let upstream: net.Socket | null = null;
|
|
93
|
-
let clientGone = false;
|
|
94
|
-
|
|
95
|
-
// A socket error with no listener is an unhandled 'error' event, which would
|
|
96
|
-
// take the whole edge process — and the public port — down with it.
|
|
97
|
-
const teardown = () => {
|
|
98
|
-
upstream?.destroy();
|
|
99
|
-
client.destroy();
|
|
100
|
-
};
|
|
101
|
-
client.on("error", teardown);
|
|
102
|
-
client.once("close", () => { clientGone = true; });
|
|
103
|
-
|
|
104
|
-
const deadline = Date.now() + connectWindowMs;
|
|
105
|
-
while (!clientGone && Date.now() < deadline) {
|
|
106
|
-
const port = resolveTargetPort();
|
|
107
|
-
if (port !== null) {
|
|
108
|
-
try {
|
|
109
|
-
upstream = await connectUpstream(port);
|
|
110
|
-
break;
|
|
111
|
-
} catch {
|
|
112
|
-
// Server not up yet (or moved mid-restart) — re-resolve and retry.
|
|
113
|
-
_resetTargetCache();
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
await sleep(CONNECT_RETRY_MS);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (clientGone) return;
|
|
120
|
-
if (!upstream) {
|
|
121
|
-
client.destroy();
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
upstream.on("error", teardown);
|
|
126
|
-
|
|
127
|
-
// Replay what arrived during the connect, then hand both directions to the
|
|
128
|
-
// stream machinery. Removing the listener and piping happen in the same tick,
|
|
129
|
-
// so no chunk can slip through the gap.
|
|
130
|
-
client.off("data", onEarlyData);
|
|
131
|
-
for (const chunk of pending) upstream.write(chunk);
|
|
132
|
-
pending.length = 0;
|
|
133
|
-
|
|
134
|
-
client.pipe(upstream);
|
|
135
|
-
upstream.pipe(client);
|
|
136
|
-
|
|
137
|
-
// The client may have half-closed while we were still connecting.
|
|
138
|
-
if (clientEnded) upstream.end();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export interface EdgeForwarderOptions {
|
|
142
|
-
publicPort: number;
|
|
143
|
-
host?: string;
|
|
144
|
-
/** How long a client waits for a reachable server. Defaults to 5s. */
|
|
145
|
-
connectWindowMs?: number;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Bind the public port and start forwarding. Resolves once listening so the
|
|
150
|
-
* caller can fail fast when the port is unavailable.
|
|
151
|
-
*/
|
|
152
|
-
export function startEdgeForwarder(
|
|
153
|
-
opts: EdgeForwarderOptions,
|
|
154
|
-
): Promise<net.Server> {
|
|
155
|
-
const host = opts.host ?? "0.0.0.0";
|
|
156
|
-
const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
|
|
157
|
-
return new Promise((resolvePromise, reject) => {
|
|
158
|
-
// allowHalfOpen keeps the other direction alive when one side sends FIN —
|
|
159
|
-
// long-lived chat WS and SSE streams depend on it.
|
|
160
|
-
const server = net.createServer({ allowHalfOpen: true }, (client) => {
|
|
161
|
-
void handleConnection(client, connectWindowMs);
|
|
162
|
-
});
|
|
163
|
-
server.once("error", reject);
|
|
164
|
-
server.listen(opts.publicPort, host, () => {
|
|
165
|
-
server.removeListener("error", reject);
|
|
166
|
-
resolvePromise(server);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── Process entry ─────────────────────────────────────────────────────
|
|
172
|
-
// Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
|
|
173
|
-
// re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
|
|
174
|
-
if (process.argv.includes("__edge__")) {
|
|
175
|
-
const idx = process.argv.indexOf("__edge__");
|
|
176
|
-
const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
|
|
177
|
-
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
178
|
-
|
|
179
|
-
if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
|
|
180
|
-
process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
|
|
181
|
-
process.exit(2);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
await startEdgeForwarder({ publicPort, host });
|
|
186
|
-
process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
|
|
187
|
-
} catch (err) {
|
|
188
|
-
process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
|
|
189
|
-
process.exit(1);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Edge forwarder — owns the public port and pipes raw TCP to the server's
|
|
3
|
+
* current loopback port.
|
|
4
|
+
*
|
|
5
|
+
* ─── CRITICAL INVARIANT: THIS PROCESS MUST NEVER SPAWN A CHILD ───
|
|
6
|
+
*
|
|
7
|
+
* On Windows a child spawned with fd stdio inherits every inheritable handle,
|
|
8
|
+
* including a listening socket. When such a descendant is orphaned it keeps the
|
|
9
|
+
* socket open, so the port stays in LISTEN under a dead PID and can never be
|
|
10
|
+
* rebound — a "zombie port". The server hits this constantly because it spawns
|
|
11
|
+
* chat/tool/MCP subprocesses; those inherit its listener and wedge its port.
|
|
12
|
+
*
|
|
13
|
+
* The edge exists precisely because it spawns nothing, so its socket cannot be
|
|
14
|
+
* inherited and its port cannot zombie. cloudflared can therefore stay pinned
|
|
15
|
+
* to it forever and the public URL stops rotating. Adding any child-process
|
|
16
|
+
* call to this file reintroduces the exact bug the edge was built to remove.
|
|
17
|
+
*
|
|
18
|
+
* Raw TCP, never HTTP: WebSocket upgrades and SSE streams pass through
|
|
19
|
+
* untouched, and forwarded requests keep the headers cloudflared set.
|
|
20
|
+
*/
|
|
21
|
+
import net from "node:net";
|
|
22
|
+
import { resolveTargetPort, _resetTargetCache } from "./edge-target-resolver.ts";
|
|
23
|
+
|
|
24
|
+
/** Delay between attempts while waiting for the server to come back. */
|
|
25
|
+
const CONNECT_RETRY_MS = 250;
|
|
26
|
+
/**
|
|
27
|
+
* How long a client connection waits for a reachable server before giving up.
|
|
28
|
+
* During an upgrade the server is briefly absent; refusing instantly would
|
|
29
|
+
* surface as a broken page rather than a slow one.
|
|
30
|
+
*/
|
|
31
|
+
const CONNECT_WINDOW_MS = 5000;
|
|
32
|
+
|
|
33
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
|
|
35
|
+
/** Resolve once the socket is connected; reject on the first connect error. */
|
|
36
|
+
function connectUpstream(port: number): Promise<net.Socket> {
|
|
37
|
+
return new Promise((resolvePromise, reject) => {
|
|
38
|
+
const upstream = net.connect({
|
|
39
|
+
port,
|
|
40
|
+
host: "127.0.0.1", // never taken from status.json — loopback only
|
|
41
|
+
allowHalfOpen: true,
|
|
42
|
+
});
|
|
43
|
+
const onError = (err: Error) => {
|
|
44
|
+
upstream.destroy();
|
|
45
|
+
reject(err);
|
|
46
|
+
};
|
|
47
|
+
upstream.once("error", onError);
|
|
48
|
+
upstream.once("connect", () => {
|
|
49
|
+
upstream.removeListener("error", onError);
|
|
50
|
+
resolvePromise(upstream);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Largest amount of client data held while waiting for the server. Bounds the
|
|
57
|
+
* memory a slow upload can pin during an upgrade window.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pipe one client connection to the server.
|
|
63
|
+
*
|
|
64
|
+
* Resolving the upstream is async, so the first bytes usually arrive before
|
|
65
|
+
* there is anywhere to send them. They must be captured by a `data` listener
|
|
66
|
+
* attached synchronously on this very tick and replayed once the upstream
|
|
67
|
+
* exists.
|
|
68
|
+
*
|
|
69
|
+
* `socket.pause()` is NOT a substitute — verified against Bun 1.3.13: bytes
|
|
70
|
+
* that arrive while paused are lost, and a later `pipe()`/`resume()` does not
|
|
71
|
+
* bring them back. A socket with no `data` listener drops them outright too.
|
|
72
|
+
* Both variants silently swallowed the first request in testing.
|
|
73
|
+
*/
|
|
74
|
+
async function handleConnection(
|
|
75
|
+
client: net.Socket,
|
|
76
|
+
connectWindowMs: number,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const pending: Buffer[] = [];
|
|
79
|
+
let pendingBytes = 0;
|
|
80
|
+
let clientEnded = false;
|
|
81
|
+
const onEarlyData = (chunk: Buffer) => {
|
|
82
|
+
pendingBytes += chunk.length;
|
|
83
|
+
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
84
|
+
client.destroy();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
pending.push(chunk);
|
|
88
|
+
};
|
|
89
|
+
client.on("data", onEarlyData);
|
|
90
|
+
client.once("end", () => { clientEnded = true; });
|
|
91
|
+
|
|
92
|
+
let upstream: net.Socket | null = null;
|
|
93
|
+
let clientGone = false;
|
|
94
|
+
|
|
95
|
+
// A socket error with no listener is an unhandled 'error' event, which would
|
|
96
|
+
// take the whole edge process — and the public port — down with it.
|
|
97
|
+
const teardown = () => {
|
|
98
|
+
upstream?.destroy();
|
|
99
|
+
client.destroy();
|
|
100
|
+
};
|
|
101
|
+
client.on("error", teardown);
|
|
102
|
+
client.once("close", () => { clientGone = true; });
|
|
103
|
+
|
|
104
|
+
const deadline = Date.now() + connectWindowMs;
|
|
105
|
+
while (!clientGone && Date.now() < deadline) {
|
|
106
|
+
const port = resolveTargetPort();
|
|
107
|
+
if (port !== null) {
|
|
108
|
+
try {
|
|
109
|
+
upstream = await connectUpstream(port);
|
|
110
|
+
break;
|
|
111
|
+
} catch {
|
|
112
|
+
// Server not up yet (or moved mid-restart) — re-resolve and retry.
|
|
113
|
+
_resetTargetCache();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await sleep(CONNECT_RETRY_MS);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (clientGone) return;
|
|
120
|
+
if (!upstream) {
|
|
121
|
+
client.destroy();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
upstream.on("error", teardown);
|
|
126
|
+
|
|
127
|
+
// Replay what arrived during the connect, then hand both directions to the
|
|
128
|
+
// stream machinery. Removing the listener and piping happen in the same tick,
|
|
129
|
+
// so no chunk can slip through the gap.
|
|
130
|
+
client.off("data", onEarlyData);
|
|
131
|
+
for (const chunk of pending) upstream.write(chunk);
|
|
132
|
+
pending.length = 0;
|
|
133
|
+
|
|
134
|
+
client.pipe(upstream);
|
|
135
|
+
upstream.pipe(client);
|
|
136
|
+
|
|
137
|
+
// The client may have half-closed while we were still connecting.
|
|
138
|
+
if (clientEnded) upstream.end();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface EdgeForwarderOptions {
|
|
142
|
+
publicPort: number;
|
|
143
|
+
host?: string;
|
|
144
|
+
/** How long a client waits for a reachable server. Defaults to 5s. */
|
|
145
|
+
connectWindowMs?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bind the public port and start forwarding. Resolves once listening so the
|
|
150
|
+
* caller can fail fast when the port is unavailable.
|
|
151
|
+
*/
|
|
152
|
+
export function startEdgeForwarder(
|
|
153
|
+
opts: EdgeForwarderOptions,
|
|
154
|
+
): Promise<net.Server> {
|
|
155
|
+
const host = opts.host ?? "0.0.0.0";
|
|
156
|
+
const connectWindowMs = opts.connectWindowMs ?? CONNECT_WINDOW_MS;
|
|
157
|
+
return new Promise((resolvePromise, reject) => {
|
|
158
|
+
// allowHalfOpen keeps the other direction alive when one side sends FIN —
|
|
159
|
+
// long-lived chat WS and SSE streams depend on it.
|
|
160
|
+
const server = net.createServer({ allowHalfOpen: true }, (client) => {
|
|
161
|
+
void handleConnection(client, connectWindowMs);
|
|
162
|
+
});
|
|
163
|
+
server.once("error", reject);
|
|
164
|
+
server.listen(opts.publicPort, host, () => {
|
|
165
|
+
server.removeListener("error", reject);
|
|
166
|
+
resolvePromise(server);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── Process entry ─────────────────────────────────────────────────────
|
|
172
|
+
// Mirrors the `__serve__` guard in src/server/index.ts: the supervisor
|
|
173
|
+
// re-invokes the binary as `<bin> __edge__ <publicPort> <host>`.
|
|
174
|
+
if (process.argv.includes("__edge__")) {
|
|
175
|
+
const idx = process.argv.indexOf("__edge__");
|
|
176
|
+
const publicPort = parseInt(process.argv[idx + 1] ?? "", 10);
|
|
177
|
+
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
178
|
+
|
|
179
|
+
if (!Number.isInteger(publicPort) || publicPort <= 0 || publicPort > 65535) {
|
|
180
|
+
process.stderr.write(`[edge] Invalid public port: ${process.argv[idx + 1]}\n`);
|
|
181
|
+
process.exit(2);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
await startEdgeForwarder({ publicPort, host });
|
|
186
|
+
process.stderr.write(`[edge] Listening on ${host}:${publicPort}\n`);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
process.stderr.write(`[edge] Failed to bind ${host}:${publicPort}: ${err}\n`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|