pi-freeflow 1.9.0 → 1.9.1

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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to pi-freeflow. Public, user-visible behavior only.
4
4
 
5
+ ## 1.9.1 - 2026-08-30
6
+
7
+ ### Fixes
8
+ - Stale-daemon guard completed: a pre-1.9.0 daemon cannot report its in-flight requests, and usage cannot be verified — so it is now left running instead of being replaced. Previously such a daemon could still be killed mid-stream while busy (a one-time window when upgrading from 1.8.x). The guarantee now holds in every case: only older, verified-idle daemons are replaced.
9
+ - The stale-daemon kill ritual was consolidated into one path (it was duplicated at five call sites); behavior unchanged.
10
+ - Tests: new coverage for the pre-1.9 daemon case; shared sandbox helpers extracted.
11
+
5
12
  ## 1.9.0 - 2026-08-30
6
13
 
7
14
  ### Development hardening
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.9.0",
4
+ "version": "1.9.1",
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/index.ts CHANGED
@@ -165,8 +165,9 @@ export function buildProviderConfig(
165
165
  * Stale-daemon replacement guard.
166
166
  * Never interrupt a working shared daemon: a newer-version daemon is left
167
167
  * running (downgrade case), an in-flight daemon is left running (busy case —
168
- * its streams would die mid-flight), and NO_KILL_ENV disables replacement
169
- * entirely. Only older, idle daemons are replaced.
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.
170
171
  */
171
172
  async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boolean> {
172
173
  if (NO_KILL_ENV && process.env[NO_KILL_ENV] === "1") {
@@ -182,7 +183,16 @@ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boo
182
183
  return false;
183
184
  }
184
185
  const health = await getDaemonHealth(port);
185
- if (health && health.activeRequests !== undefined && health.activeRequests > 0) {
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) {
186
196
  logInfo(
187
197
  `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${port} (${health.activeRequests} active request${health.activeRequests === 1 ? "" : "s"} — not interrupted)`,
188
198
  );
@@ -190,6 +200,33 @@ async function shouldReplaceDaemon(port: number, remoteVer: string): Promise<boo
190
200
  }
191
201
  return true;
192
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
+ }
193
230
  /**
194
231
  * Main extension entrypoint
195
232
  */
@@ -209,19 +246,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
209
246
  let alreadyRunning = await isProxyAlive(PORT);
210
247
  if (alreadyRunning) {
211
248
  const remoteVer = await getDaemonVersion(PORT);
212
- if (remoteVer !== null && remoteVer !== PKG_VERSION && (await shouldReplaceDaemon(PORT, remoteVer))) {
213
- logWarn(`stale proxy daemon v${remoteVer} on :${PORT} (need v${PKG_VERSION}) — replacing`, {
214
- remoteVer,
215
- expected: PKG_VERSION,
216
- });
217
- const killed = await killPortHolder(PORT);
218
- if (killed) {
219
- for (let i = 0; i < 10; i++) {
220
- await new Promise<void>((r) => setTimeout(r, 200));
221
- if (!(await isProxyAlive(PORT))) break;
222
- }
223
- }
224
- if (!(await isProxyAlive(PORT))) {
249
+ if (remoteVer !== null && remoteVer !== PKG_VERSION) {
250
+ if (await killStaleDaemon(PORT, remoteVer, "proxy daemon")) {
225
251
  try {
226
252
  const r = await startProxy();
227
253
  server = r.server;
@@ -234,7 +260,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
234
260
  actualPort = PORT;
235
261
  }
236
262
  } else {
237
- logInfo(`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
238
263
  actualPort = PORT;
239
264
  }
240
265
  } else {
@@ -243,19 +268,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
243
268
  }
244
269
  } else if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
245
270
  const remoteVer = await getDaemonVersion(LEGACY_PORT);
246
- if (remoteVer !== null && remoteVer !== PKG_VERSION && (await shouldReplaceDaemon(LEGACY_PORT, remoteVer))) {
247
- logWarn(`stale legacy daemon v${remoteVer} on :${LEGACY_PORT} (need v${PKG_VERSION}) — replacing`, {
248
- remoteVer,
249
- expected: PKG_VERSION,
250
- });
251
- const killed = await killPortHolder(LEGACY_PORT);
252
- if (killed) {
253
- for (let i = 0; i < 10; i++) {
254
- await new Promise<void>((r) => setTimeout(r, 200));
255
- if (!(await isProxyAlive(LEGACY_PORT))) break;
256
- }
257
- }
258
- if (!(await isProxyAlive(LEGACY_PORT)) && !(await isProxyAlive(PORT))) {
271
+ if (remoteVer !== null && remoteVer !== PKG_VERSION) {
272
+ if (await killStaleDaemon(LEGACY_PORT, remoteVer, "legacy proxy daemon")) {
259
273
  try {
260
274
  const r = await startProxy();
261
275
  server = r.server;
@@ -269,7 +283,6 @@ export default async function (pi: ExtensionAPI): Promise<void> {
269
283
  actualPort = LEGACY_PORT;
270
284
  }
271
285
  } else {
272
- logInfo(`Reusing existing legacy pi-freeflow proxy daemon on http://${HOST}:${LEGACY_PORT}`);
273
286
  alreadyRunning = true;
274
287
  actualPort = LEGACY_PORT;
275
288
  }
@@ -313,73 +326,45 @@ export default async function (pi: ExtensionAPI): Promise<void> {
313
326
  if (await isProxyAlive(actualPort)) {
314
327
  const v = await getDaemonVersion(actualPort);
315
328
  if (v === null || v === PKG_VERSION) return;
316
- if (!(await shouldReplaceDaemon(actualPort, v))) return;
317
- logWarn(`proxy on :${actualPort} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
318
- remoteVer: v,
319
- expected: PKG_VERSION,
320
- });
321
- const killed = await killPortHolder(actualPort);
322
- if (killed) {
323
- for (let i = 0; i < 10; i++) {
324
- await new Promise<void>((r) => setTimeout(r, 200));
325
- if (!(await isProxyAlive(actualPort))) break;
326
- }
327
- }
328
- if (await isProxyAlive(actualPort)) return;
329
+ if (!(await killStaleDaemon(actualPort, v, "proxy daemon"))) return;
329
330
  }
330
331
  if (await isProxyAlive(PORT)) {
331
332
  const v = await getDaemonVersion(PORT);
332
- if (v !== null && v !== PKG_VERSION && (await shouldReplaceDaemon(PORT, v))) {
333
- logWarn(`proxy on :${PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
334
- remoteVer: v,
335
- expected: PKG_VERSION,
336
- });
337
- const killed = await killPortHolder(PORT);
338
- if (killed) {
339
- for (let i = 0; i < 10; i++) {
340
- await new Promise<void>((r) => setTimeout(r, 200));
341
- if (!(await isProxyAlive(PORT))) break;
342
- }
343
- }
344
- if (await isProxyAlive(PORT)) {
345
- actualPort = PORT;
346
- registerCatalog(getAliveCatalog());
347
- logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT} (stale kill did not free port)`);
348
- return;
349
- }
350
- } else {
333
+ if (v === null || v === PKG_VERSION) {
351
334
  actualPort = PORT;
352
335
  registerCatalog(getAliveCatalog());
353
336
  logInfo(`Re-attached to proxy daemon on http://${HOST}:${PORT}`);
354
337
  return;
355
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
+ }
356
349
  }
357
350
  if (PORT !== LEGACY_PORT && (await isProxyAlive(LEGACY_PORT))) {
358
351
  const v = await getDaemonVersion(LEGACY_PORT);
359
- if (v !== null && v !== PKG_VERSION && (await shouldReplaceDaemon(LEGACY_PORT, v))) {
360
- logWarn(`legacy proxy on :${LEGACY_PORT} is stale v${v} (need v${PKG_VERSION}) — replacing`, {
361
- remoteVer: v,
362
- expected: PKG_VERSION,
363
- });
364
- const killed = await killPortHolder(LEGACY_PORT);
365
- if (killed) {
366
- for (let i = 0; i < 10; i++) {
367
- await new Promise<void>((r) => setTimeout(r, 200));
368
- if (!(await isProxyAlive(LEGACY_PORT))) break;
369
- }
370
- }
371
- if (await isProxyAlive(LEGACY_PORT)) {
372
- actualPort = LEGACY_PORT;
373
- registerCatalog(getAliveCatalog());
374
- logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT} (stale kill did not free port)`);
375
- return;
376
- }
377
- } else {
352
+ if (v === null || v === PKG_VERSION) {
378
353
  actualPort = LEGACY_PORT;
379
354
  registerCatalog(getAliveCatalog());
380
355
  logInfo(`Re-attached to legacy proxy daemon on http://${HOST}:${LEGACY_PORT}`);
381
356
  return;
382
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
+ }
383
368
  }
384
369
  const r = await startProxy();
385
370
  if (r.server) server = r.server;