pi-freeflow 1.9.1 → 1.9.4
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 +37 -0
- package/README.md +10 -2
- package/package.json +2 -4
- package/src/client.ts +323 -0
- package/src/commands.ts +41 -24
- package/src/config.ts +28 -1
- package/src/daemon.ts +140 -0
- package/src/health.ts +27 -20
- package/src/index.ts +17 -208
- package/src/lease.ts +106 -0
- package/src/proxy.ts +86 -8
- package/src/relay.ts +4 -17
package/src/health.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type * as http from "node:http";
|
|
7
7
|
import { ALL_MODELS } from "./models.ts";
|
|
8
8
|
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
|
+
import { getLastActivityAt, getLeaseCount, getLeaseSnapshot } from "./lease.ts";
|
|
9
10
|
import { PKG_VERSION, PORT } from "./config.ts";
|
|
10
11
|
|
|
11
12
|
export interface HealthRelayInfo {
|
|
@@ -26,7 +27,14 @@ export interface HealthData {
|
|
|
26
27
|
version: string;
|
|
27
28
|
/** In-flight proxied requests right now (stale-daemon replacement guard). */
|
|
28
29
|
activeRequests: number;
|
|
30
|
+
/** Clients holding a live lease (detached-daemon GC). */
|
|
31
|
+
clients: number;
|
|
32
|
+
/** clientId -> lastSeenAt for every live lease. */
|
|
33
|
+
leases: Record<string, number>;
|
|
34
|
+
/** Last time any request was proxied (request-touch for legacy clients). */
|
|
35
|
+
lastActivityAt: number;
|
|
29
36
|
}
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* Collect current health snapshot.
|
|
32
40
|
* @param portOverride - actual listening port (defaults to config PORT)
|
|
@@ -54,10 +62,16 @@ export function getHealthData(portOverride?: number, activeRequests = 0): Health
|
|
|
54
62
|
catalog: ALL_MODELS.length,
|
|
55
63
|
version: PKG_VERSION,
|
|
56
64
|
activeRequests,
|
|
65
|
+
clients: getLeaseCount(),
|
|
66
|
+
leases: getLeaseSnapshot(),
|
|
67
|
+
lastActivityAt: getLastActivityAt(),
|
|
57
68
|
};
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Check whether an IP address is a loopback address (127.0.0.1, ::1, localhost).
|
|
73
|
+
*/
|
|
74
|
+
export function isLoopbackIP(ip: string): boolean {
|
|
61
75
|
if (!ip) return false;
|
|
62
76
|
const withoutZone = ip.split("%")[0];
|
|
63
77
|
const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
|
|
@@ -75,38 +89,31 @@ export function handleHealthRequest(
|
|
|
75
89
|
portOverride?: number,
|
|
76
90
|
activeRequests = 0,
|
|
77
91
|
): boolean {
|
|
78
|
-
|
|
92
|
+
if (req.method !== "GET") return false;
|
|
93
|
+
|
|
94
|
+
let reqPathname: string | null = null;
|
|
79
95
|
try {
|
|
80
|
-
|
|
96
|
+
reqPathname = new URL(req.url ?? "/", `http://127.0.0.1`).pathname;
|
|
81
97
|
} catch {
|
|
82
98
|
return false;
|
|
83
99
|
}
|
|
100
|
+
if (reqPathname === null) return false;
|
|
101
|
+
if (reqPathname !== "/_health" && reqPathname !== "/health") return false;
|
|
84
102
|
|
|
85
|
-
const
|
|
86
|
-
if (req.method !== "GET" || !isHealthPath) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const sock: unknown = req.socket;
|
|
91
|
-
let rawIp = "";
|
|
92
|
-
if (sock && typeof sock === "object" && "remoteAddress" in sock) {
|
|
93
|
-
const v = sock.remoteAddress;
|
|
94
|
-
if (typeof v === "string") rawIp = v;
|
|
95
|
-
}
|
|
96
|
-
const clientIP = rawIp.startsWith("::ffff:") ? rawIp.slice(7) : rawIp;
|
|
103
|
+
const clientIP = req.socket.remoteAddress ?? "";
|
|
97
104
|
if (!isLoopbackIP(clientIP)) {
|
|
98
|
-
|
|
99
|
-
res.
|
|
100
|
-
res.end(body);
|
|
105
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
106
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
101
107
|
return true;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
const data = getHealthData(portOverride, activeRequests);
|
|
105
|
-
const body = JSON.stringify(data);
|
|
111
|
+
const body = JSON.stringify(data, null, 2);
|
|
106
112
|
res.writeHead(200, {
|
|
107
113
|
"content-type": "application/json",
|
|
108
114
|
"content-length": Buffer.byteLength(body),
|
|
115
|
+
"cache-control": "no-store",
|
|
109
116
|
});
|
|
110
117
|
res.end(body);
|
|
111
118
|
return true;
|
|
112
|
-
}
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import type * as http from "node:http";
|
|
13
12
|
import {
|
|
14
13
|
getAliveCatalog,
|
|
15
14
|
mergeCatalog,
|
|
@@ -17,13 +16,12 @@ import {
|
|
|
17
16
|
refreshCatalog,
|
|
18
17
|
setAliveCatalog,
|
|
19
18
|
} from "./catalog.ts";
|
|
19
|
+
import { ensureDaemon as ensureClientDaemon, getClientPort, stopHeartbeat } from "./client.ts";
|
|
20
20
|
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
21
|
-
import { HOST,
|
|
22
|
-
import {
|
|
21
|
+
import { HOST, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
22
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
23
23
|
import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
24
|
-
import {
|
|
25
|
-
import { resetRateLimits } from "./rate-limiter.ts";
|
|
26
|
-
import { checkForUpdateInBackground, compareVersions } from "./update-checker.ts";
|
|
24
|
+
import { checkForUpdateInBackground } from "./update-checker.ts";
|
|
27
25
|
import {
|
|
28
26
|
ensureRelay,
|
|
29
27
|
getActiveRelayState,
|
|
@@ -160,150 +158,18 @@ export function buildProviderConfig(
|
|
|
160
158
|
}),
|
|
161
159
|
};
|
|
162
160
|
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Stale-daemon replacement guard.
|
|
166
|
-
* Never interrupt a working shared daemon: a newer-version daemon is left
|
|
167
|
-
* running (downgrade case), an in-flight daemon is left running (busy case —
|
|
168
|
-
* its streams would die mid-flight), a pre-1.9 daemon that cannot report
|
|
169
|
-
* usage is left running (cannot verify), and NO_KILL_ENV disables replacement
|
|
170
|
-
* entirely. Only older, VERIFIED-idle daemons are replaced.
|
|
171
|
-
*/
|
|
172
|
-
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
173
|
-
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
174
|
-
logInfo(
|
|
175
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
176
|
-
);
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
180
|
-
logInfo(
|
|
181
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
182
|
-
);
|
|
183
|
-
return false;
|
|
184
|
-
}
|
|
185
|
-
const health = await getDaemonHealth(port);
|
|
186
|
-
if (health === null || health.activeRequests === undefined) {
|
|
187
|
-
// Pre-1.9 daemons do not report in-flight requests: usage cannot be
|
|
188
|
-
// verified, so the running daemon is left untouched. Interrupting a
|
|
189
|
-
// possibly-busy session is worse than keeping an older daemon alive.
|
|
190
|
-
logInfo(
|
|
191
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
192
|
-
);
|
|
193
|
-
return false;
|
|
194
|
-
}
|
|
195
|
-
if (health.activeRequests > 0) {
|
|
196
|
-
logInfo(
|
|
197
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
198
|
-
);
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Guarded kill ritual for a stale daemon: decide (older-only, verified-idle,
|
|
206
|
-
* NO_KILL) → log → kill → wait for the port to free. Returns true when the
|
|
207
|
-
* port stopped answering (caller re-binds or re-attaches below); false when
|
|
208
|
-
* the daemon is left running — every skip reason is logged here.
|
|
209
|
-
*/
|
|
210
|
-
async function killStaleDaemon(
|
|
211
|
-
port: number,
|
|
212
|
-
remoteVer: string,
|
|
213
|
-
what: string,
|
|
214
|
-
): Promise<boolean> {
|
|
215
|
-
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
216
|
-
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
217
|
-
remoteVer,
|
|
218
|
-
expected: PKG_VERSION,
|
|
219
|
-
});
|
|
220
|
-
await killPortHolder(port);
|
|
221
|
-
for (let i = 0; i < 10; i++) {
|
|
222
|
-
await new Promise<void>((r) => setTimeout(r, 200));
|
|
223
|
-
if (!(await isProxyAlive(port))) return true;
|
|
224
|
-
}
|
|
225
|
-
logInfo(
|
|
226
|
-
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
227
|
-
);
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
161
|
/**
|
|
231
162
|
* Main extension entrypoint
|
|
232
163
|
*/
|
|
233
164
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
234
165
|
logInfo("pi-freeflow extension initializing...");
|
|
235
166
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// sessions on 18080 are seamlessly reused without split-brain or duplicate daemons.
|
|
243
|
-
// Stale-daemon heal: if the alive daemon reports a different version (e.g. 1.7.1 vs 1.8.0
|
|
244
|
-
// after an upgrade), the auth fix never loads for the new session. Detect via /_health
|
|
245
|
-
// and best-effort replace the stale holder so old users are auto-healed.
|
|
246
|
-
let alreadyRunning = await isProxyAlive(PORT);
|
|
247
|
-
if (alreadyRunning) {
|
|
248
|
-
const remoteVer = await getDaemonVersion(PORT);
|
|
249
|
-
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
250
|
-
if (await killStaleDaemon(PORT, remoteVer, "proxy daemon")) {
|
|
251
|
-
try {
|
|
252
|
-
const r = await startProxy();
|
|
253
|
-
server = r.server;
|
|
254
|
-
actualPort = r.port;
|
|
255
|
-
alreadyRunning = false;
|
|
256
|
-
} catch (e) {
|
|
257
|
-
log("error", "stale daemon replaced but fresh bind failed — reusing stale as fallback", {
|
|
258
|
-
error: String(e),
|
|
259
|
-
});
|
|
260
|
-
actualPort = PORT;
|
|
261
|
-
}
|
|
262
|
-
} else {
|
|
263
|
-
actualPort = PORT;
|
|
264
|
-
}
|
|
265
|
-
} else {
|
|
266
|
-
logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`);
|
|
267
|
-
actualPort = PORT;
|
|
268
|
-
}
|
|
269
|
-
} else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
270
|
-
const remoteVer = await getDaemonVersion(LEGACY_PORT);
|
|
271
|
-
if (remoteVer !== null && remoteVer !== PKG_VERSION) {
|
|
272
|
-
if (await killStaleDaemon(LEGACY_PORT, remoteVer, "legacy proxy daemon")) {
|
|
273
|
-
try {
|
|
274
|
-
const r = await startProxy();
|
|
275
|
-
server = r.server;
|
|
276
|
-
actualPort = r.port;
|
|
277
|
-
alreadyRunning = false;
|
|
278
|
-
} catch (e) {
|
|
279
|
-
log("error", "stale legacy daemon replaced but fresh bind failed — reusing as fallback", {
|
|
280
|
-
error: String(e),
|
|
281
|
-
});
|
|
282
|
-
alreadyRunning = true;
|
|
283
|
-
actualPort = LEGACY_PORT;
|
|
284
|
-
}
|
|
285
|
-
} else {
|
|
286
|
-
alreadyRunning = true;
|
|
287
|
-
actualPort = LEGACY_PORT;
|
|
288
|
-
}
|
|
289
|
-
} else {
|
|
290
|
-
logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
291
|
-
alreadyRunning = true;
|
|
292
|
-
actualPort = LEGACY_PORT;
|
|
293
|
-
}
|
|
294
|
-
} else {
|
|
295
|
-
try {
|
|
296
|
-
const r = await startProxy();
|
|
297
|
-
server = r.server;
|
|
298
|
-
actualPort = r.port;
|
|
299
|
-
} catch (e) {
|
|
300
|
-
log(
|
|
301
|
-
"error",
|
|
302
|
-
"extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
|
|
303
|
-
{ error: String(e) },
|
|
304
|
-
);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
167
|
+
let actualPort: number;
|
|
168
|
+
try {
|
|
169
|
+
actualPort = await ensureClientDaemon();
|
|
170
|
+
} catch (e) {
|
|
171
|
+
logWarn("daemon ensure failed — using configured port", { error: String(e) });
|
|
172
|
+
actualPort = getClientPort();
|
|
307
173
|
}
|
|
308
174
|
// Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
|
|
309
175
|
const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
@@ -318,63 +184,16 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
318
184
|
buildProviderConfig(models, actualPort),
|
|
319
185
|
);
|
|
320
186
|
};
|
|
321
|
-
let ensuringDaemon = false;
|
|
322
187
|
const ensureDaemon = async (): Promise<void> => {
|
|
323
|
-
if (ensuringDaemon) return;
|
|
324
|
-
ensuringDaemon = true;
|
|
325
188
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (await isProxyAlive(PORT)) {
|
|
332
|
-
const v = await getDaemonVersion(PORT);
|
|
333
|
-
if (v === null || v === PKG_VERSION) {
|
|
334
|
-
actualPort = PORT;
|
|
335
|
-
registerCatalog(getAliveCatalog());
|
|
336
|
-
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
if (await killStaleDaemon(PORT, v, "proxy daemon")) {
|
|
340
|
-
// Port freed — fall through to legacy probe / re-bind below.
|
|
341
|
-
} else if (await isProxyAlive(PORT)) {
|
|
342
|
-
actualPort = PORT;
|
|
343
|
-
registerCatalog(getAliveCatalog());
|
|
344
|
-
logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
|
|
345
|
-
return;
|
|
346
|
-
} else {
|
|
347
|
-
return; // daemon replaced; re-bind happens below
|
|
348
|
-
}
|
|
189
|
+
const port = await ensureClientDaemon();
|
|
190
|
+
if (port !== actualPort) {
|
|
191
|
+
actualPort = port;
|
|
192
|
+
registerCatalog(getAliveCatalog());
|
|
193
|
+
logInfo(`Re-attached to proxy daemon on http://${HOST}:${port}`);
|
|
349
194
|
}
|
|
350
|
-
if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
|
|
351
|
-
const v = await getDaemonVersion(LEGACY_PORT);
|
|
352
|
-
if (v === null || v === PKG_VERSION) {
|
|
353
|
-
actualPort = LEGACY_PORT;
|
|
354
|
-
registerCatalog(getAliveCatalog());
|
|
355
|
-
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
if (await killStaleDaemon(LEGACY_PORT, v, "legacy proxy daemon")) {
|
|
359
|
-
// Port freed — fall through to the re-bind below.
|
|
360
|
-
} else if (await isProxyAlive(LEGACY_PORT)) {
|
|
361
|
-
actualPort = LEGACY_PORT;
|
|
362
|
-
registerCatalog(getAliveCatalog());
|
|
363
|
-
logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
|
|
364
|
-
return;
|
|
365
|
-
} else {
|
|
366
|
-
return; // daemon replaced; re-bind happens below
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
const r = await startProxy();
|
|
370
|
-
if (r.server) server = r.server;
|
|
371
|
-
if (r.port) actualPort = r.port;
|
|
372
|
-
registerCatalog(getAliveCatalog());
|
|
373
|
-
logWarn("proxy daemon was lost — re-bound locally", { port: actualPort });
|
|
374
195
|
} catch (e) {
|
|
375
|
-
logWarn("proxy daemon
|
|
376
|
-
} finally {
|
|
377
|
-
ensuringDaemon = false;
|
|
196
|
+
logWarn("proxy daemon re-attach failed", { error: String(e) });
|
|
378
197
|
}
|
|
379
198
|
};
|
|
380
199
|
|
|
@@ -510,16 +329,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
510
329
|
}
|
|
511
330
|
});
|
|
512
331
|
pi.on?.("session_shutdown", () => {
|
|
513
|
-
|
|
514
|
-
logInfo("shutting down proxy daemon...");
|
|
515
|
-
const closing = server;
|
|
516
|
-
server = null;
|
|
517
|
-
closing.close();
|
|
518
|
-
// Node 18.2+: closeIdleConnections exists at runtime even if lib types lag
|
|
519
|
-
const closable = closing as unknown as { closeIdleConnections?: () => void };
|
|
520
|
-
closable.closeIdleConnections?.();
|
|
521
|
-
resetRateLimits();
|
|
522
|
-
logInfo("shutdown complete");
|
|
523
|
-
}
|
|
332
|
+
stopHeartbeat();
|
|
524
333
|
});
|
|
525
334
|
}
|
package/src/lease.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client lease registry for the detached pi-freeflow proxy daemon.
|
|
3
|
+
*
|
|
4
|
+
* Each OMP/Pi session is a client that registers a lease and renews it with a
|
|
5
|
+
* heartbeat while alive. The daemon drops expired leases and, once NO client
|
|
6
|
+
* holds a live lease AND no request has been proxied recently, retires itself.
|
|
7
|
+
*
|
|
8
|
+
* The request-touch (`lastActivityAt`) is the fallback for legacy clients that
|
|
9
|
+
* never heartbeated: any proxied request counts as a live user, so the daemon
|
|
10
|
+
* is never idle-killed while a session is actually using it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface LeaseOptions {
|
|
14
|
+
/** Lease lifetime (ms); a client that misses ~3 beats is dropped. */
|
|
15
|
+
ttlMs: number;
|
|
16
|
+
/** GC sweep interval (ms). */
|
|
17
|
+
gcMs: number;
|
|
18
|
+
/** Idle grace after the last proxied request before a lease-less daemon exits (ms). */
|
|
19
|
+
graceMs: number;
|
|
20
|
+
/** Current in-flight proxied requests — daemon never exits mid-stream. */
|
|
21
|
+
getActiveRequests: () => number;
|
|
22
|
+
/** Called once when the daemon should retire (close server + exit). */
|
|
23
|
+
onIdle: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const leases = new Map<string, number>();
|
|
27
|
+
let lastActivityAt = Date.now();
|
|
28
|
+
let gcTimer: ReturnType<typeof setInterval> | null = null;
|
|
29
|
+
|
|
30
|
+
/** Register or refresh a client lease. */
|
|
31
|
+
export function registerClient(clientId: string): void {
|
|
32
|
+
leases.set(clientId, Date.now());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Renew an existing client lease (unknown ids are ignored). */
|
|
36
|
+
export function renewClient(clientId: string): void {
|
|
37
|
+
if (leases.has(clientId)) {
|
|
38
|
+
leases.set(clientId, Date.now());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Remove a client lease (graceful detach on session end). */
|
|
43
|
+
export function unregisterClient(clientId: string): void {
|
|
44
|
+
leases.delete(clientId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Number of clients holding a live lease. */
|
|
48
|
+
export function getLeaseCount(): number {
|
|
49
|
+
return leases.size;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Snapshot of live client leases (id -> lastSeenAt) for health/debugging. */
|
|
53
|
+
export function getLeaseSnapshot(): Record<string, number> {
|
|
54
|
+
return Object.fromEntries(leases);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Record proxy activity — any proxied request counts as a live user. */
|
|
58
|
+
export function touchActivity(): void {
|
|
59
|
+
lastActivityAt = Date.now();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Timestamp of the last proxied request (0 = never; daemon inits at bind). */
|
|
63
|
+
export function getLastActivityAt(): number {
|
|
64
|
+
return lastActivityAt;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start the lease GC sweep. Prunes expired leases and, when no client holds a
|
|
69
|
+
* lease, nothing is in flight, and no request has been proxied within the
|
|
70
|
+
* grace window, invokes `onIdle` (the daemon retires). Idempotent — a second
|
|
71
|
+
* call is a no-op.
|
|
72
|
+
*/
|
|
73
|
+
export function startLeaseGC(opts: LeaseOptions): void {
|
|
74
|
+
if (gcTimer !== null) return;
|
|
75
|
+
gcTimer = setInterval(() => {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
for (const [id, seenAt] of leases) {
|
|
78
|
+
if (now - seenAt > opts.ttlMs) {
|
|
79
|
+
leases.delete(id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
leases.size === 0 &&
|
|
84
|
+
opts.getActiveRequests() === 0 &&
|
|
85
|
+
now - lastActivityAt > opts.graceMs
|
|
86
|
+
) {
|
|
87
|
+
stopLeaseGC();
|
|
88
|
+
opts.onIdle();
|
|
89
|
+
}
|
|
90
|
+
}, opts.gcMs);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stop the GC sweep (test teardown / daemon shutdown). */
|
|
94
|
+
export function stopLeaseGC(): void {
|
|
95
|
+
if (gcTimer !== null) {
|
|
96
|
+
clearInterval(gcTimer);
|
|
97
|
+
gcTimer = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Test-only: reset all lease state. */
|
|
102
|
+
export function _resetLeaseStateForTest(): void {
|
|
103
|
+
leases.clear();
|
|
104
|
+
lastActivityAt = Date.now();
|
|
105
|
+
stopLeaseGC();
|
|
106
|
+
}
|
package/src/proxy.ts
CHANGED
|
@@ -9,9 +9,11 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
10
|
import * as http from "node:http";
|
|
11
11
|
import * as https from "node:https";
|
|
12
|
-
import { handleHealthRequest } from "./health.ts";
|
|
12
|
+
import { handleHealthRequest, isLoopbackIP } from "./health.ts";
|
|
13
|
+
import { registerClient, renewClient, touchActivity, unregisterClient } from "./lease.ts";
|
|
13
14
|
import { Readable } from "node:stream";
|
|
14
15
|
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
16
|
+
|
|
15
17
|
import { getAliveCatalog } from "./catalog.ts";
|
|
16
18
|
import {
|
|
17
19
|
ALLOWED_METHODS,
|
|
@@ -25,15 +27,22 @@ import {
|
|
|
25
27
|
UPSTREAM_OPENCODE,
|
|
26
28
|
opencodeHeaders,
|
|
27
29
|
} from "./config.ts";
|
|
30
|
+
|
|
28
31
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
29
32
|
import { KILO_MODEL_IDS, resolveCanonicalModelId } from "./models.ts";
|
|
30
33
|
// normalize removed — host pi-ai already normalizes thinking/reasoning before proxy
|
|
31
34
|
import { checkRateLimit } from "./rate-limiter.ts";
|
|
32
|
-
import {
|
|
35
|
+
import { relayFetch } from "./relay.ts";
|
|
33
36
|
import { getActiveRelayState } from "./relay-state.ts";
|
|
34
37
|
import { pipeUpstreamStream } from "./stream-pipe.ts";
|
|
35
38
|
import type { Upstream } from "./types.ts";
|
|
36
39
|
|
|
40
|
+
|
|
41
|
+
let shutdownShouldExit = false;
|
|
42
|
+
export function setShutdownShouldExit(v: boolean): void {
|
|
43
|
+
shutdownShouldExit = v;
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
/**
|
|
38
47
|
* Direct-mode 429 hint throttle: the guidance hint is emitted at most once per
|
|
39
48
|
* 10 minutes per process so repeated rate-limit responses don't spam clients.
|
|
@@ -173,7 +182,11 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
173
182
|
try {
|
|
174
183
|
if (process.platform === "win32") {
|
|
175
184
|
try {
|
|
176
|
-
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
185
|
+
const out = execSync(`netstat -ano | findstr :${port}`, {
|
|
186
|
+
encoding: "utf8",
|
|
187
|
+
timeout: 3000,
|
|
188
|
+
windowsHide: true,
|
|
189
|
+
}) as string;
|
|
177
190
|
for (const line of out.split("\n")) {
|
|
178
191
|
if (!line.includes("LISTENING")) continue;
|
|
179
192
|
if (!line.includes(`:${port}`)) continue;
|
|
@@ -182,7 +195,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
182
195
|
if (!pid || !/^\d+$/.test(pid)) continue;
|
|
183
196
|
// Windows netstat report: 127.0.0.1:38180 ... LISTENING/PID
|
|
184
197
|
if (Number(pid) === process.pid) continue; // never self-kill
|
|
185
|
-
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore" });
|
|
198
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 3000, stdio: "ignore", windowsHide: true });
|
|
186
199
|
return true;
|
|
187
200
|
}
|
|
188
201
|
} catch {}
|
|
@@ -206,6 +219,71 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Loopback-only client lease + control endpoints used by the detached-daemon
|
|
224
|
+
* protocol (src/client.ts). Returns false when the request is not a control
|
|
225
|
+
* endpoint (caller continues). Control writes are async (chunked JSON).
|
|
226
|
+
*/
|
|
227
|
+
export function handleControlRequest(
|
|
228
|
+
req: http.IncomingMessage,
|
|
229
|
+
res: http.ServerResponse,
|
|
230
|
+
reqPathname: string | null,
|
|
231
|
+
closeServer: () => void,
|
|
232
|
+
): boolean {
|
|
233
|
+
if (reqPathname === null) return false;
|
|
234
|
+
const isShutdown = reqPathname === "/_shutdown";
|
|
235
|
+
const isClientRoute = reqPathname.startsWith("/_client/");
|
|
236
|
+
if (!isShutdown && !isClientRoute) return false;
|
|
237
|
+
const clientIP = req.socket.remoteAddress ?? "";
|
|
238
|
+
if (!isLoopbackIP(clientIP)) {
|
|
239
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
240
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
if (isShutdown) {
|
|
244
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({ ok: true }));
|
|
246
|
+
setTimeout(() => {
|
|
247
|
+
try { closeServer(); } catch {}
|
|
248
|
+
if (shutdownShouldExit) process.exit(0);
|
|
249
|
+
}, 50);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
const chunks: Buffer[] = [];
|
|
253
|
+
let total = 0;
|
|
254
|
+
req.on("data", (c: Buffer) => {
|
|
255
|
+
total += c.length;
|
|
256
|
+
if (total > 4096) { req.destroy(); return; }
|
|
257
|
+
chunks.push(c);
|
|
258
|
+
});
|
|
259
|
+
req.on("end", () => {
|
|
260
|
+
let clientId = "";
|
|
261
|
+
try {
|
|
262
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
263
|
+
if (parsed && typeof parsed === "object" && "id" in parsed) {
|
|
264
|
+
const candidate = parsed.id;
|
|
265
|
+
if (typeof candidate === "string" && candidate) clientId = candidate;
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
if (!clientId) {
|
|
269
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
270
|
+
res.end(JSON.stringify({ error: "missing client id" }));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (reqPathname === "/_client/attach") registerClient(clientId);
|
|
274
|
+
else if (reqPathname === "/_client/heartbeat") renewClient(clientId);
|
|
275
|
+
else if (reqPathname === "/_client/detach") unregisterClient(clientId);
|
|
276
|
+
else {
|
|
277
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
278
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
282
|
+
res.end(JSON.stringify({ ok: true }));
|
|
283
|
+
});
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
/**
|
|
210
288
|
* Tagged abort reason for the proxy-internal header-wait timeout.
|
|
211
289
|
* relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
|
|
@@ -213,9 +291,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
213
291
|
* rolls nor penalizes a healthy relay when the request merely ran slow.
|
|
214
292
|
*/
|
|
215
293
|
function upstreamTimeoutError(): Error & { code: string } {
|
|
216
|
-
const err = new Error(
|
|
217
|
-
`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`,
|
|
218
|
-
) as Error & { code: string };
|
|
294
|
+
const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
|
|
219
295
|
err.name = "AbortError";
|
|
220
296
|
err.code = "FF_INTERNAL_ABORT";
|
|
221
297
|
return err;
|
|
@@ -261,6 +337,7 @@ export function startProxy(
|
|
|
261
337
|
try {
|
|
262
338
|
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
263
339
|
} catch {}
|
|
340
|
+
if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
|
|
264
341
|
// Loopback-only health endpoint — always accessible even when widget hidden
|
|
265
342
|
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
266
343
|
const addr = server.address();
|
|
@@ -268,6 +345,7 @@ export function startProxy(
|
|
|
268
345
|
if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
|
|
269
346
|
}
|
|
270
347
|
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
348
|
+
touchActivity();
|
|
271
349
|
const alive = getAliveCatalog();
|
|
272
350
|
const body = JSON.stringify({
|
|
273
351
|
object: "list",
|
|
@@ -298,6 +376,7 @@ export function startProxy(
|
|
|
298
376
|
}
|
|
299
377
|
return;
|
|
300
378
|
}
|
|
379
|
+
touchActivity();
|
|
301
380
|
// Buffer request body to inspect model ID for upstream routing
|
|
302
381
|
const bodyChunks: Buffer[] = [];
|
|
303
382
|
activeRequests += 1;
|
|
@@ -566,7 +645,6 @@ export function startProxy(
|
|
|
566
645
|
headers: fwd,
|
|
567
646
|
body: directBody.length > 0 ? directBody : undefined,
|
|
568
647
|
signal: controller.signal,
|
|
569
|
-
...(canUseCustomDispatcher ? { dispatcher: agent } : {}),
|
|
570
648
|
} as unknown as RequestInit);
|
|
571
649
|
clearTimeout(timeoutId);
|
|
572
650
|
res.off("close", onClientClose);
|