pi-freeflow 1.9.1 → 1.9.2
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 +19 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/client.ts +313 -0
- package/src/commands.ts +17 -1
- 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 +79 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-freeflow. Public, user-visible behavior only.
|
|
4
4
|
|
|
5
|
+
## 1.9.2 - 2026-08-31
|
|
6
|
+
|
|
7
|
+
### Fixes
|
|
8
|
+
- **Closing one session no longer stops the shared local proxy.** The proxy daemon
|
|
9
|
+
is now a fully detached background process: it outlives any single OMP/Pi session
|
|
10
|
+
(previously, closing the session that owned the daemon could shut it down even
|
|
11
|
+
while other sessions were still using it). It retires by itself only when no
|
|
12
|
+
session is connected, no request is in flight, and it has been idle for a grace
|
|
13
|
+
period. The next use starts it again automatically.
|
|
14
|
+
- New command: `/freeflow kill` (aliases `stop`, `shutdown`) stops the background
|
|
15
|
+
daemon on demand. The next freeflow use restarts it.
|
|
16
|
+
|
|
17
|
+
### Improvements
|
|
18
|
+
- The proxy tracks connected sessions and last request time; `/freeflow status`
|
|
19
|
+
and the health endpoint now report active session leases, so you can see when
|
|
20
|
+
other sessions are keeping the daemon alive.
|
|
21
|
+
- Docs: the command reference now lists `kill`, and the FAQ explains the shared
|
|
22
|
+
daemon lifecycle (survives session close; self-retires when unused).
|
|
23
|
+
|
|
5
24
|
## 1.9.1 - 2026-08-30
|
|
6
25
|
|
|
7
26
|
### Fixes
|
package/README.md
CHANGED
|
@@ -117,6 +117,7 @@ Manage your relay pool directly from the OMP / Pi terminal:
|
|
|
117
117
|
/freeflow refresh # Reload the model catalog from live upstreams
|
|
118
118
|
/freeflow update # Check for and install a package update
|
|
119
119
|
/freeflow debug on | off # Toggle full HTTP lifecycle debug logging
|
|
120
|
+
/freeflow kill # Stop the shared proxy daemon now (restarts on next use)
|
|
120
121
|
```
|
|
121
122
|
|
|
122
123
|
---
|
|
@@ -286,6 +287,13 @@ with a log note instead). If a daemon cannot be replaced (e.g. port held by an u
|
|
|
286
287
|
it falls back to reusing it with a warning. To disable replacement entirely, set the no-kill env
|
|
287
288
|
to `1` before starting a session.
|
|
288
289
|
|
|
290
|
+
**What happens when I close a session?**
|
|
291
|
+
Nothing visible to your other sessions. The proxy daemon is a separate background
|
|
292
|
+
process shared by every OMP/Pi session on the machine. Closing one session just
|
|
293
|
+
unregisters it; the daemon keeps serving the rest and retires itself automatically
|
|
294
|
+
once the last client disconnects and it has been idle for a short grace period.
|
|
295
|
+
To stop it manually, run `/freeflow kill` — the next freeflow use starts it again.
|
|
296
|
+
|
|
289
297
|
**Where's the normalizer?**
|
|
290
298
|
Deleted in 1.3.0. If zai/qwen/deepseek thinking broke before, it's fixed now because host handles it.
|
|
291
299
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.2",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side daemon lifecycle for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Every OMP/Pi session is a client. It attaches to the shared detached daemon
|
|
5
|
+
* at 127.0.0.1:28180 (or spawns one if none is alive), registers a lease, and
|
|
6
|
+
* renews it with a heartbeat while the session lives. When the session ends
|
|
7
|
+
* the heartbeat stops; the daemon drops the lease after its TTL and retires
|
|
8
|
+
* once no client holds a live lease and no request has been proxied recently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import type * as http from "node:http";
|
|
16
|
+
import {
|
|
17
|
+
DAEMON_HEARTBEAT_MS,
|
|
18
|
+
DAEMON_HEARTBEAT_MS_ENV,
|
|
19
|
+
DAEMON_READY_TIMEOUT_MS,
|
|
20
|
+
DAEMON_SPAWN_ENV,
|
|
21
|
+
HOST,
|
|
22
|
+
LEGACY_PORT,
|
|
23
|
+
NO_KILL_ENV,
|
|
24
|
+
PKG_VERSION,
|
|
25
|
+
PORT,
|
|
26
|
+
} from "./config.ts";
|
|
27
|
+
import { logInfo, logWarn } from "./logger.ts";
|
|
28
|
+
import {
|
|
29
|
+
getDaemonHealth,
|
|
30
|
+
getDaemonVersion,
|
|
31
|
+
isProxyAlive,
|
|
32
|
+
killPortHolder,
|
|
33
|
+
startProxy,
|
|
34
|
+
} from "./proxy.ts";
|
|
35
|
+
import { compareVersions } from "./update-checker.ts";
|
|
36
|
+
|
|
37
|
+
const CLIENT_ID = randomUUID();
|
|
38
|
+
|
|
39
|
+
let attachedPort = 0;
|
|
40
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let heartbeatPort = 0;
|
|
42
|
+
let ensuring = false;
|
|
43
|
+
let fallbackServer: http.Server | null = null;
|
|
44
|
+
|
|
45
|
+
function isBunRuntime(): boolean {
|
|
46
|
+
return typeof (process.versions as unknown as Record<string, string>).bun === "string";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function daemonScriptPath(): string {
|
|
50
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.ts");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getHeartbeatMs(): number {
|
|
54
|
+
const raw = process.env[DAEMON_HEARTBEAT_MS_ENV];
|
|
55
|
+
if (raw) {
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
58
|
+
}
|
|
59
|
+
return DAEMON_HEARTBEAT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSpawnEnabled(): boolean {
|
|
63
|
+
return process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ControlResult = "ok" | "legacy" | "gone";
|
|
67
|
+
|
|
68
|
+
async function controlCall(
|
|
69
|
+
port: number,
|
|
70
|
+
endpoint: string,
|
|
71
|
+
payload: Record<string, string>,
|
|
72
|
+
): Promise<ControlResult> {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`http://${HOST}:${port}${endpoint}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
signal: AbortSignal.timeout(1500),
|
|
79
|
+
});
|
|
80
|
+
return res.ok ? "ok" : "legacy";
|
|
81
|
+
} catch {
|
|
82
|
+
return "gone";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function startHeartbeat(port: number): void {
|
|
87
|
+
stopHeartbeatInternal();
|
|
88
|
+
heartbeatPort = port;
|
|
89
|
+
const ms = getHeartbeatMs();
|
|
90
|
+
heartbeatTimer = setInterval(() => {
|
|
91
|
+
void beatOnce(port);
|
|
92
|
+
}, ms);
|
|
93
|
+
try {
|
|
94
|
+
heartbeatTimer.unref();
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stopHeartbeatInternal(): void {
|
|
99
|
+
if (heartbeatTimer !== null) {
|
|
100
|
+
clearInterval(heartbeatTimer);
|
|
101
|
+
heartbeatTimer = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function stopHeartbeat(): void {
|
|
106
|
+
const port = heartbeatPort;
|
|
107
|
+
stopHeartbeatInternal();
|
|
108
|
+
heartbeatPort = 0;
|
|
109
|
+
if (port) {
|
|
110
|
+
void controlCall(port, "/_client/detach", { id: CLIENT_ID });
|
|
111
|
+
}
|
|
112
|
+
if (fallbackServer) {
|
|
113
|
+
try {
|
|
114
|
+
fallbackServer.close();
|
|
115
|
+
} catch {}
|
|
116
|
+
fallbackServer = null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function beatOnce(port: number): Promise<void> {
|
|
121
|
+
if (ensuring) return;
|
|
122
|
+
const result = await controlCall(port, "/_client/heartbeat", { id: CLIENT_ID });
|
|
123
|
+
if (result === "gone") {
|
|
124
|
+
void ensureDaemon();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function attachTo(port: number): Promise<void> {
|
|
129
|
+
attachedPort = port;
|
|
130
|
+
const result = await controlCall(port, "/_client/attach", { id: CLIENT_ID });
|
|
131
|
+
if (result === "gone") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
startHeartbeat(port);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
|
|
138
|
+
if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
|
|
139
|
+
logInfo(
|
|
140
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (replacement disabled by env)`,
|
|
141
|
+
);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (compareVersions(remoteVer, PKG_VERSION) > 0) {
|
|
145
|
+
logInfo(
|
|
146
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (newer daemon v${remoteVer} left running)`,
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const health = await getDaemonHealth(port);
|
|
151
|
+
if (health === null || health.activeRequests === undefined) {
|
|
152
|
+
logInfo(
|
|
153
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (cannot verify usage — leaving the running daemon untouched)`,
|
|
154
|
+
);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
if (health.activeRequests > 0) {
|
|
158
|
+
logInfo(
|
|
159
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
|
|
160
|
+
);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function killStaleDaemon(
|
|
167
|
+
port: number,
|
|
168
|
+
remoteVer: string,
|
|
169
|
+
what: string,
|
|
170
|
+
): Promise<boolean> {
|
|
171
|
+
if (!(await shouldReplaceDaemon(port, remoteVer))) return false;
|
|
172
|
+
logWarn(`stale ${what} v${remoteVer} on :${port} (need v${PKG_VERSION}) — replacing`, {
|
|
173
|
+
remoteVer,
|
|
174
|
+
expected: PKG_VERSION,
|
|
175
|
+
});
|
|
176
|
+
await killPortHolder(port);
|
|
177
|
+
for (let i = 0; i < 10; i++) {
|
|
178
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
179
|
+
if (!(await isProxyAlive(port))) return true;
|
|
180
|
+
}
|
|
181
|
+
logInfo(
|
|
182
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (stale kill did not free port)`,
|
|
183
|
+
);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function spawnDaemonProcess(): void {
|
|
188
|
+
const script = daemonScriptPath();
|
|
189
|
+
const args = isBunRuntime() ? [script] : ["--experimental-strip-types", script];
|
|
190
|
+
try {
|
|
191
|
+
const child = spawn(process.execPath, args, {
|
|
192
|
+
detached: true,
|
|
193
|
+
stdio: "ignore",
|
|
194
|
+
windowsHide: true,
|
|
195
|
+
});
|
|
196
|
+
child.unref();
|
|
197
|
+
child.on("error", (err) => {
|
|
198
|
+
logWarn("daemon spawn failed", { error: String(err) });
|
|
199
|
+
});
|
|
200
|
+
} catch (e) {
|
|
201
|
+
logWarn("daemon spawn failed", { error: String(e) });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function waitForReady(port: number, timeoutMs: number): Promise<boolean> {
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
while (Date.now() < deadline) {
|
|
208
|
+
if (await isProxyAlive(port)) return true;
|
|
209
|
+
await new Promise<void>((r) => setTimeout(r, 200));
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function probeAndMaybeReplace(
|
|
215
|
+
port: number,
|
|
216
|
+
label: string,
|
|
217
|
+
): Promise<number | null> {
|
|
218
|
+
if (!(await isProxyAlive(port))) return null;
|
|
219
|
+
const ver = await getDaemonVersion(port);
|
|
220
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
221
|
+
if (await killStaleDaemon(port, ver, label)) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
if (await isProxyAlive(port)) return port;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
return port;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Ensure a proxy daemon is running and attach this client to it.
|
|
232
|
+
* Returns the port the caller should use for its ProviderConfig.
|
|
233
|
+
*/
|
|
234
|
+
export async function ensureDaemon(): Promise<number> {
|
|
235
|
+
if (ensuring) return attachedPort || PORT;
|
|
236
|
+
ensuring = true;
|
|
237
|
+
try {
|
|
238
|
+
const primary = await probeAndMaybeReplace(PORT, "proxy daemon");
|
|
239
|
+
if (primary !== null) {
|
|
240
|
+
await attachTo(primary);
|
|
241
|
+
return primary;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (PORT !== LEGACY_PORT) {
|
|
245
|
+
const legacy = await probeAndMaybeReplace(LEGACY_PORT, "legacy proxy daemon");
|
|
246
|
+
if (legacy !== null) {
|
|
247
|
+
await attachTo(legacy);
|
|
248
|
+
return legacy;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isSpawnEnabled()) {
|
|
253
|
+
spawnDaemonProcess();
|
|
254
|
+
const ready = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
255
|
+
if (ready) {
|
|
256
|
+
const ver = await getDaemonVersion(PORT);
|
|
257
|
+
if (ver !== null && ver !== PKG_VERSION) {
|
|
258
|
+
if (await killStaleDaemon(PORT, ver, "proxy daemon")) {
|
|
259
|
+
spawnDaemonProcess();
|
|
260
|
+
const retryReady = await waitForReady(PORT, DAEMON_READY_TIMEOUT_MS);
|
|
261
|
+
if (retryReady) {
|
|
262
|
+
await attachTo(PORT);
|
|
263
|
+
return PORT;
|
|
264
|
+
}
|
|
265
|
+
} else if (await isProxyAlive(PORT)) {
|
|
266
|
+
await attachTo(PORT);
|
|
267
|
+
return PORT;
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
await attachTo(PORT);
|
|
271
|
+
return PORT;
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
logWarn("daemon spawn did not become ready — is the port blocked?");
|
|
275
|
+
}
|
|
276
|
+
return PORT;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const r = await startProxy();
|
|
281
|
+
if (r.server) fallbackServer = r.server;
|
|
282
|
+
const port = r.port;
|
|
283
|
+
await attachTo(port);
|
|
284
|
+
return port;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
logWarn("in-process fallback bind failed", { error: String(e) });
|
|
287
|
+
return PORT;
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
ensuring = false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getClientPort(): number {
|
|
295
|
+
return attachedPort || PORT;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function getClientId(): string {
|
|
299
|
+
return CLIENT_ID;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function _resetClientForTest(): void {
|
|
303
|
+
stopHeartbeatInternal();
|
|
304
|
+
heartbeatPort = 0;
|
|
305
|
+
attachedPort = 0;
|
|
306
|
+
ensuring = false;
|
|
307
|
+
if (fallbackServer) {
|
|
308
|
+
try {
|
|
309
|
+
fallbackServer.close();
|
|
310
|
+
} catch {}
|
|
311
|
+
fallbackServer = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
+
import { getClientPort } from "./client.ts";
|
|
8
9
|
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
9
|
-
import { DEBUG_STATE_FILE, LOG_FILE, RELAY_STATE_FILE } from "./config.ts";
|
|
10
|
+
import { DEBUG_STATE_FILE, HOST, LOG_FILE, PORT, RELAY_STATE_FILE } from "./config.ts";
|
|
10
11
|
import {
|
|
11
12
|
compareVersions,
|
|
12
13
|
fetchLatestVersion,
|
|
@@ -532,6 +533,21 @@ export function createCommandSpec(
|
|
|
532
533
|
}`;
|
|
533
534
|
const stateFileLine = `State file: ${RELAY_STATE_FILE}`;
|
|
534
535
|
ctx.ui.notify(`${modeLine} | ${poolLine}\n${stateFileLine}`, "info");
|
|
536
|
+
} else if (sub === "kill" || sub === "stop" || sub === "shutdown") {
|
|
537
|
+
const port = getClientPort() || PORT;
|
|
538
|
+
try {
|
|
539
|
+
const res = await fetch(`http://${HOST}:${port}/_shutdown`, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
signal: AbortSignal.timeout(1500),
|
|
542
|
+
});
|
|
543
|
+
if (res.ok) {
|
|
544
|
+
ctx.ui.notify("Proxy daemon stopped — next freeflow use restarts it", "info");
|
|
545
|
+
} else {
|
|
546
|
+
ctx.ui.notify(`Daemon kill got HTTP ${res.status}`, "warning");
|
|
547
|
+
}
|
|
548
|
+
} catch {
|
|
549
|
+
ctx.ui.notify("Daemon not running or not reachable", "warning");
|
|
550
|
+
}
|
|
535
551
|
} else if (sub === "update") {
|
|
536
552
|
if (isLinkedInstall()) {
|
|
537
553
|
ctx.ui.notify(
|
package/src/config.ts
CHANGED
|
@@ -205,7 +205,34 @@ export const UPDATE_CHECK_TTL_MS = 86_400_000;
|
|
|
205
205
|
export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
|
|
206
206
|
/** Opt-out for the stale-daemon replace: when "1", a version-mismatched daemon is never killed. */
|
|
207
207
|
export const NO_KILL_ENV = ALLOW_UNSAFE_RELAY_ENV.replace("_ALLOW_UNSAFE_RELAY", "_NO_KILL");
|
|
208
|
-
/**
|
|
208
|
+
/** When "0", the extension never spawns a detached proxy daemon (tests/CI). */
|
|
209
|
+
export const DAEMON_SPAWN_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_SPAWN");
|
|
210
|
+
/** Lease TTL for attached clients (ms); expired leases are dropped by the daemon GC. */
|
|
211
|
+
export const DAEMON_TTL_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_TTL_MS");
|
|
212
|
+
/** Client heartbeat interval (ms) — must stay well under the lease TTL. */
|
|
213
|
+
export const DAEMON_HEARTBEAT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_HEARTBEAT_MS");
|
|
214
|
+
/** Daemon GC sweep interval (ms). */
|
|
215
|
+
export const DAEMON_GC_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GC_MS");
|
|
216
|
+
/** Idle grace after the last request before a lease-less daemon exits (ms). */
|
|
217
|
+
export const DAEMON_GRACE_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_GRACE_MS");
|
|
218
|
+
/** Max time a client waits for a freshly spawned daemon to answer /_health (ms). */
|
|
219
|
+
export const DAEMON_READY_TIMEOUT_MS_ENV = DATA_DIR_ENV.replace("_DATA_DIR", "_DAEMON_READY_TIMEOUT_MS");
|
|
220
|
+
|
|
221
|
+
function envMs(name: string, fallback: number): number {
|
|
222
|
+
const raw = process.env[name];
|
|
223
|
+
if (raw) {
|
|
224
|
+
const parsed = Number(raw);
|
|
225
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
226
|
+
}
|
|
227
|
+
return fallback;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const DAEMON_SPAWN_ENABLED = process.env[DAEMON_SPAWN_ENV] !== "0";
|
|
231
|
+
export const DAEMON_TTL_MS = envMs(DAEMON_TTL_MS_ENV, 30_000);
|
|
232
|
+
export const DAEMON_HEARTBEAT_MS = envMs(DAEMON_HEARTBEAT_MS_ENV, 10_000);
|
|
233
|
+
export const DAEMON_GC_MS = envMs(DAEMON_GC_MS_ENV, 5_000);
|
|
234
|
+
export const DAEMON_GRACE_MS = envMs(DAEMON_GRACE_MS_ENV, 10_000);
|
|
235
|
+
export const DAEMON_READY_TIMEOUT_MS = envMs(DAEMON_READY_TIMEOUT_MS_ENV, 5_000);
|
|
209
236
|
export const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
210
237
|
/** Default timeout for upstream headers while proxying a request. */
|
|
211
238
|
export const UPSTREAM_HEADER_TIMEOUT_MS = 300_000;
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detached proxy daemon entry for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Spawned as a separate OS process by src/client.ts so the local proxy survives
|
|
5
|
+
* the OMP/Pi session that started it. Owns port 28180, serves the proxy plus
|
|
6
|
+
* the client lease/control endpoints, and retires itself once no client holds
|
|
7
|
+
* a live lease and no request has been proxied recently.
|
|
8
|
+
*
|
|
9
|
+
* Run directly: `node --experimental-strip-types src/daemon.ts` (or `bun src/daemon.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
DAEMON_GC_MS,
|
|
16
|
+
DAEMON_GRACE_MS,
|
|
17
|
+
DAEMON_TTL_MS,
|
|
18
|
+
HOST,
|
|
19
|
+
PKG_VERSION,
|
|
20
|
+
PORT,
|
|
21
|
+
} from "./config.ts";
|
|
22
|
+
import { setShutdownShouldExit } from "./proxy.ts";
|
|
23
|
+
import {
|
|
24
|
+
getAliveCatalog,
|
|
25
|
+
readCatalogCache,
|
|
26
|
+
refreshCatalog,
|
|
27
|
+
setAliveCatalog,
|
|
28
|
+
} from "./catalog.ts";
|
|
29
|
+
import { log, logInfo, logWarn } from "./logger.ts";
|
|
30
|
+
import {
|
|
31
|
+
startLeaseGC,
|
|
32
|
+
stopLeaseGC,
|
|
33
|
+
touchActivity,
|
|
34
|
+
} from "./lease.ts";
|
|
35
|
+
import { getActiveRequests, startProxy } from "./proxy.ts";
|
|
36
|
+
import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
|
|
37
|
+
|
|
38
|
+
/** True when this module was run as the entry script (not imported by tests). */
|
|
39
|
+
export function isDaemonMain(): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const entry = process.argv[1];
|
|
42
|
+
if (!entry) return false;
|
|
43
|
+
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Load the on-disk relay state into this process's in-memory cache so routing
|
|
51
|
+
* uses the latest client edits without a daemon restart. Non-fatal on failure.
|
|
52
|
+
*/
|
|
53
|
+
export function syncRelayStateFromDisk(): void {
|
|
54
|
+
try {
|
|
55
|
+
setActiveRelayState(loadRelayState(), false);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log("warn", "daemon failed to load relay state from disk", { error: String(e) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serve the static+disk catalog and refresh it in the background, mirroring
|
|
63
|
+
* what the host session did in-process. Keeps /v1/models correct after the
|
|
64
|
+
* 24h cache TTL without requiring a daemon restart.
|
|
65
|
+
*/
|
|
66
|
+
export async function seedCatalog(): Promise<void> {
|
|
67
|
+
const cached = readCatalogCache();
|
|
68
|
+
if (cached && Array.isArray(cached.models) && cached.models.length > 0) {
|
|
69
|
+
setAliveCatalog(cached.models);
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const fresh = await refreshCatalog(false);
|
|
73
|
+
if (fresh.length > 0) {
|
|
74
|
+
setAliveCatalog(fresh);
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
logWarn("daemon catalog refresh failed; retaining cached catalog", {
|
|
78
|
+
error: String(e),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
logInfo(`daemon catalog ready: ${getAliveCatalog().length} models`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Boot the detached proxy daemon. Binds the port, seeds catalog + relay state,
|
|
86
|
+
* starts the lease GC, and installs signal handlers so the daemon exits
|
|
87
|
+
* cleanly on SIGTERM/SIGINT (e.g. /freeflow kill, taskkill, Ctrl+C).
|
|
88
|
+
*/
|
|
89
|
+
export async function runDaemon(): Promise<void> {
|
|
90
|
+
let server: { close(): void } | null = null;
|
|
91
|
+
const retire = (why: string): void => {
|
|
92
|
+
logInfo(`daemon retiring: ${why}`);
|
|
93
|
+
stopLeaseGC();
|
|
94
|
+
try {
|
|
95
|
+
server?.close();
|
|
96
|
+
} catch {}
|
|
97
|
+
process.exit(0);
|
|
98
|
+
};
|
|
99
|
+
process.on("SIGTERM", () => retire("SIGTERM"));
|
|
100
|
+
process.on("SIGINT", () => retire("SIGINT"));
|
|
101
|
+
setShutdownShouldExit(true);
|
|
102
|
+
try {
|
|
103
|
+
const r = await startProxy();
|
|
104
|
+
if (!r.server) {
|
|
105
|
+
// Port was taken by another (possibly newer) daemon — the parent
|
|
106
|
+
// attaches to the winner; this spawn exits quietly.
|
|
107
|
+
logInfo(`daemon found an existing proxy on http://${HOST}:${r.port} — exiting`);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
server = r.server;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
log("error", "daemon failed to bind proxy port", { error: String(e) });
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// lastActivityAt is initialized AT BIND TIME: a freshly started daemon with
|
|
117
|
+
// zero leases must never be GC'd during the parent's readiness-poll window.
|
|
118
|
+
touchActivity();
|
|
119
|
+
syncRelayStateFromDisk();
|
|
120
|
+
void seedCatalog();
|
|
121
|
+
|
|
122
|
+
startLeaseGC({
|
|
123
|
+
ttlMs: DAEMON_TTL_MS,
|
|
124
|
+
gcMs: DAEMON_GC_MS,
|
|
125
|
+
graceMs: DAEMON_GRACE_MS,
|
|
126
|
+
getActiveRequests,
|
|
127
|
+
onIdle: () => retire("no clients and idle"),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
logInfo(`pi-freeflow daemon v${PKG_VERSION} listening on http://${HOST}:${PORT}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Entry guard: run only when executed as the entry script. When imported by
|
|
134
|
+
// tests, isDaemonMain() is false and no port is bound.
|
|
135
|
+
if (isDaemonMain()) {
|
|
136
|
+
runDaemon().catch((e) => {
|
|
137
|
+
log("error", "daemon crashed", { error: String(e) });
|
|
138
|
+
process.exit(1);
|
|
139
|
+
});
|
|
140
|
+
}
|
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,6 +27,7 @@ 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
|
|
@@ -34,6 +37,12 @@ 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.
|
|
@@ -206,6 +215,71 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
206
215
|
}
|
|
207
216
|
}
|
|
208
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Loopback-only client lease + control endpoints used by the detached-daemon
|
|
220
|
+
* protocol (src/client.ts). Returns false when the request is not a control
|
|
221
|
+
* endpoint (caller continues). Control writes are async (chunked JSON).
|
|
222
|
+
*/
|
|
223
|
+
export function handleControlRequest(
|
|
224
|
+
req: http.IncomingMessage,
|
|
225
|
+
res: http.ServerResponse,
|
|
226
|
+
reqPathname: string | null,
|
|
227
|
+
closeServer: () => void,
|
|
228
|
+
): boolean {
|
|
229
|
+
if (reqPathname === null) return false;
|
|
230
|
+
const isShutdown = reqPathname === "/_shutdown";
|
|
231
|
+
const isClientRoute = reqPathname.startsWith("/_client/");
|
|
232
|
+
if (!isShutdown && !isClientRoute) return false;
|
|
233
|
+
const clientIP = req.socket.remoteAddress ?? "";
|
|
234
|
+
if (!isLoopbackIP(clientIP)) {
|
|
235
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
236
|
+
res.end(JSON.stringify({ error: "loopback only" }));
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
if (isShutdown) {
|
|
240
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
241
|
+
res.end(JSON.stringify({ ok: true }));
|
|
242
|
+
setTimeout(() => {
|
|
243
|
+
try { closeServer(); } catch {}
|
|
244
|
+
if (shutdownShouldExit) process.exit(0);
|
|
245
|
+
}, 50);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
const chunks: Buffer[] = [];
|
|
249
|
+
let total = 0;
|
|
250
|
+
req.on("data", (c: Buffer) => {
|
|
251
|
+
total += c.length;
|
|
252
|
+
if (total > 4096) { req.destroy(); return; }
|
|
253
|
+
chunks.push(c);
|
|
254
|
+
});
|
|
255
|
+
req.on("end", () => {
|
|
256
|
+
let clientId = "";
|
|
257
|
+
try {
|
|
258
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
259
|
+
if (parsed && typeof parsed === "object" && "id" in parsed) {
|
|
260
|
+
const candidate = parsed.id;
|
|
261
|
+
if (typeof candidate === "string" && candidate) clientId = candidate;
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
264
|
+
if (!clientId) {
|
|
265
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
266
|
+
res.end(JSON.stringify({ error: "missing client id" }));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (reqPathname === "/_client/attach") registerClient(clientId);
|
|
270
|
+
else if (reqPathname === "/_client/heartbeat") renewClient(clientId);
|
|
271
|
+
else if (reqPathname === "/_client/detach") unregisterClient(clientId);
|
|
272
|
+
else {
|
|
273
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
274
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
278
|
+
res.end(JSON.stringify({ ok: true }));
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
209
283
|
/**
|
|
210
284
|
* Tagged abort reason for the proxy-internal header-wait timeout.
|
|
211
285
|
* relayFetch rethrows AbortErrors untouched, and stream-pipe recognizes
|
|
@@ -213,9 +287,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
|
|
|
213
287
|
* rolls nor penalizes a healthy relay when the request merely ran slow.
|
|
214
288
|
*/
|
|
215
289
|
function upstreamTimeoutError(): Error & { code: string } {
|
|
216
|
-
const err = new Error(
|
|
217
|
-
`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`,
|
|
218
|
-
) as Error & { code: string };
|
|
290
|
+
const err = new Error(`upstream header timeout (${UPSTREAM_HEADER_TIMEOUT_MS}ms)`) as Error & { code: string };
|
|
219
291
|
err.name = "AbortError";
|
|
220
292
|
err.code = "FF_INTERNAL_ABORT";
|
|
221
293
|
return err;
|
|
@@ -261,6 +333,7 @@ export function startProxy(
|
|
|
261
333
|
try {
|
|
262
334
|
reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
|
|
263
335
|
} catch {}
|
|
336
|
+
if (handleControlRequest(req, res, reqPathname, () => server.close())) return;
|
|
264
337
|
// Loopback-only health endpoint — always accessible even when widget hidden
|
|
265
338
|
if (req.method === "GET" && reqPathname !== null && (reqPathname === "/_health" || reqPathname === "/health")) {
|
|
266
339
|
const addr = server.address();
|
|
@@ -268,6 +341,7 @@ export function startProxy(
|
|
|
268
341
|
if (handleHealthRequest(req, res, realPort, getActiveRequests())) return;
|
|
269
342
|
}
|
|
270
343
|
if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
|
|
344
|
+
touchActivity();
|
|
271
345
|
const alive = getAliveCatalog();
|
|
272
346
|
const body = JSON.stringify({
|
|
273
347
|
object: "list",
|
|
@@ -298,6 +372,7 @@ export function startProxy(
|
|
|
298
372
|
}
|
|
299
373
|
return;
|
|
300
374
|
}
|
|
375
|
+
touchActivity();
|
|
301
376
|
// Buffer request body to inspect model ID for upstream routing
|
|
302
377
|
const bodyChunks: Buffer[] = [];
|
|
303
378
|
activeRequests += 1;
|