@lincoln504/pi-research 1.1.0 → 1.2.0
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/README.md +4 -4
- package/dist/cli.mjs +1404 -600
- package/dist/thread-worker.mjs +40 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +107 -1
- package/docs/CONFIGURATION.md +10 -8
- package/docs/KNOWLEDGE-STORE.md +5 -4
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +63 -5
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/build.cjs +23 -5
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +8 -5
- package/skills/pi-research/references/configuration.md +7 -3
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +161 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/llm/model-registry-factory.ts +40 -3
- package/src/core/llm/research-model-resolver.ts +2 -2
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +12 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +40 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +34 -92
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +23 -2
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +20 -0
- package/src/utils/citation-utils.ts +106 -8
- package/src/utils/http-body.ts +176 -0
- package/src/utils/llm-usage.ts +37 -0
- package/src/utils/metrics-summary.ts +25 -2
- package/src/utils/research-export.ts +9 -2
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/retry-utils.ts +13 -1
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -23,6 +23,21 @@ import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts
|
|
|
23
23
|
*/
|
|
24
24
|
const lockContext = new AsyncLocalStorage<Set<string>>();
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* How long cleanup() waits for an in-flight critical section to release normally
|
|
28
|
+
* before forcing the teardown. Long enough for an ordinary state write (temp file
|
|
29
|
+
* + fsync + rename) to finish, short enough that shutdown still makes progress
|
|
30
|
+
* when the holder is wedged.
|
|
31
|
+
*/
|
|
32
|
+
const CLEANUP_DRAIN_TIMEOUT_MS = 5_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Age past which an orphaned `<lock>.trash.<hex>` copy is swept at startup. An
|
|
36
|
+
* in-flight trash-and-verify holds its copy for milliseconds; anything this old
|
|
37
|
+
* was leaked by a crash mid-dance.
|
|
38
|
+
*/
|
|
39
|
+
const TRASH_SWEEP_AGE_MS = 10 * 60_000;
|
|
40
|
+
|
|
26
41
|
/**
|
|
27
42
|
* Lock configuration options
|
|
28
43
|
*/
|
|
@@ -83,6 +98,9 @@ export class FileLockService implements IService {
|
|
|
83
98
|
// PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
|
|
84
99
|
// then lock content omits startTime and readers treat us as a legacy owner.
|
|
85
100
|
private ownStartTime: number | null = null;
|
|
101
|
+
// Set by cleanup(). A retired instance no longer manages the lock file, so
|
|
102
|
+
// acquiring through it would hand out exclusivity it cannot honour.
|
|
103
|
+
private _retired = false;
|
|
86
104
|
|
|
87
105
|
constructor(options: FileLockOptions) {
|
|
88
106
|
this.lockFilePath = options.lockFilePath;
|
|
@@ -175,11 +193,111 @@ export class FileLockService implements IService {
|
|
|
175
193
|
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
176
194
|
}
|
|
177
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Put a lock file back after a trash-and-verify decided not to reclaim it.
|
|
198
|
+
*
|
|
199
|
+
* The old form linked it back inside a swallowing catch and then unlinked the
|
|
200
|
+
* trash copy unconditionally — so any link failure other than "a peer already
|
|
201
|
+
* re-created it" DESTROYED the lock this branch exists to preserve. Distinguish
|
|
202
|
+
* the two: if the path is occupied again the peer's lock wins and ours is dropped;
|
|
203
|
+
* otherwise move ours back rather than deleting it.
|
|
204
|
+
*/
|
|
205
|
+
private async _restoreLockFile(trashPath: string): Promise<void> {
|
|
206
|
+
try {
|
|
207
|
+
await fs.link(trashPath, this.lockFilePath);
|
|
208
|
+
await fs.unlink(trashPath).catch(() => { /* link succeeded; copy is redundant */ });
|
|
209
|
+
return;
|
|
210
|
+
} catch { /* fall through to the two cases below */ }
|
|
211
|
+
|
|
212
|
+
const peerHoldsIt = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
|
|
213
|
+
if (peerHoldsIt) {
|
|
214
|
+
// Someone re-created the lock while we were checking. Theirs is authoritative.
|
|
215
|
+
await fs.unlink(trashPath).catch(() => { /* best effort */ });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Nothing there and link failed (EPERM, filesystems without hardlinks,
|
|
219
|
+
// Windows link limits): re-create the path EXCLUSIVELY rather than rename it
|
|
220
|
+
// back — rename silently REPLACES a lock a peer creates between the probe
|
|
221
|
+
// above and now, clobbering a live owner. 'wx' loses that race loudly, and
|
|
222
|
+
// then the peer's lock is authoritative.
|
|
223
|
+
try {
|
|
224
|
+
const content = await fs.readFile(trashPath, 'utf-8');
|
|
225
|
+
const handle = await fs.open(this.lockFilePath, 'wx', 0o600);
|
|
226
|
+
try {
|
|
227
|
+
await handle.write(content);
|
|
228
|
+
await handle.sync();
|
|
229
|
+
} finally {
|
|
230
|
+
await handle.close().catch(() => { /* content is on disk */ });
|
|
231
|
+
}
|
|
232
|
+
await fs.unlink(trashPath).catch(() => { /* restored; copy is redundant */ });
|
|
233
|
+
} catch {
|
|
234
|
+
// EEXIST (a peer took the path) — ours is redundant; anything else, leave
|
|
235
|
+
// the trash copy for the startup sweep. Never delete what we could not
|
|
236
|
+
// put back.
|
|
237
|
+
const occupied = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
|
|
238
|
+
if (occupied) await fs.unlink(trashPath).catch(() => { /* best effort */ });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Hand our turn back to the next caller in the FIFO queue.
|
|
244
|
+
*
|
|
245
|
+
* Every path that stops holding the lock must run this, including the ones that
|
|
246
|
+
* find the handle already gone. `acquireLock` parks on `await previous` with no
|
|
247
|
+
* timeout of its own — the lockTimeout only starts counting once the turn has
|
|
248
|
+
* been granted — so a turn that is never resolved is not a slow acquire, it is a
|
|
249
|
+
* permanent one.
|
|
250
|
+
*/
|
|
251
|
+
private _releaseTurn(): void {
|
|
252
|
+
const resolve = this.resolveTurn;
|
|
253
|
+
this.resolveTurn = null;
|
|
254
|
+
if (resolve) resolve();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Wait, bounded, for an in-flight critical section to finish.
|
|
259
|
+
*
|
|
260
|
+
* Yanking a held lock is not a clean teardown: unlinking the file while the
|
|
261
|
+
* holder is still mid read-modify-write drops cross-process exclusion, and a peer
|
|
262
|
+
* that acquires in that window loses the holder's update — precisely the hazard
|
|
263
|
+
* the lock exists to prevent. Since every caller of {@link cleanup} is a shutdown
|
|
264
|
+
* path, waiting briefly for a normal release is both safe and correct.
|
|
265
|
+
*
|
|
266
|
+
* Bounded because shutdown must make progress regardless: if the holder is wedged
|
|
267
|
+
* (or is itself the caller, which would otherwise deadlock), we give up and force
|
|
268
|
+
* the teardown, which is the pre-existing behaviour.
|
|
269
|
+
*/
|
|
270
|
+
private async _drainHeldLock(timeoutMs: number): Promise<boolean> {
|
|
271
|
+
const deadline = Date.now() + timeoutMs;
|
|
272
|
+
while (this.lockHandle !== null && Date.now() < deadline) {
|
|
273
|
+
await this.sleep(Math.min(25, this.lockRetryDelay));
|
|
274
|
+
}
|
|
275
|
+
return this.lockHandle === null;
|
|
276
|
+
}
|
|
277
|
+
|
|
178
278
|
/**
|
|
179
279
|
* Clean up any stale lock files on initialization.
|
|
180
280
|
* This handles cases where locks weren't released due to crashes.
|
|
181
281
|
*/
|
|
182
282
|
private async cleanupStaleLocksOnStartup(): Promise<void> {
|
|
283
|
+
// Sweep trash copies leaked by an interrupted trash-and-verify (a crash or
|
|
284
|
+
// I/O failure between the rename and the unlink/restore). Nothing else ever
|
|
285
|
+
// collects them: the state-dir orphan sweep matches only *.tmp files. The age
|
|
286
|
+
// gate is generous — an in-flight reclaim's trash copy lives for milliseconds,
|
|
287
|
+
// and a restore-worthy one is put back on the same code path that made it.
|
|
288
|
+
try {
|
|
289
|
+
const dir = pathmod.dirname(this.lockFilePath);
|
|
290
|
+
const prefix = `${pathmod.basename(this.lockFilePath)}.trash.`;
|
|
291
|
+
for (const name of await fs.readdir(dir)) {
|
|
292
|
+
if (!name.startsWith(prefix)) continue;
|
|
293
|
+
const p = pathmod.join(dir, name);
|
|
294
|
+
const st = await fs.stat(p).catch(() => null);
|
|
295
|
+
if (st && Date.now() - st.mtimeMs > TRASH_SWEEP_AGE_MS) {
|
|
296
|
+
await fs.unlink(p).catch(() => { /* best effort */ });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch { /* best-effort hygiene; never blocks startup */ }
|
|
300
|
+
|
|
183
301
|
try {
|
|
184
302
|
const rawContent = await fs.readFile(this.lockFilePath, 'utf-8').catch(() => null);
|
|
185
303
|
if (rawContent === null) return; // No lock file — nothing to do
|
|
@@ -201,19 +319,25 @@ export class FileLockService implements IService {
|
|
|
201
319
|
// the UUID matches what we decided on — same guard as the contended path.
|
|
202
320
|
const expectedUuid = parsed?.uuid ?? '';
|
|
203
321
|
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
|
|
322
|
+
let renamed = false;
|
|
204
323
|
try {
|
|
205
324
|
await fs.rename(this.lockFilePath, trashPath);
|
|
325
|
+
renamed = true;
|
|
206
326
|
const trashContent = await fs.readFile(trashPath, 'utf-8');
|
|
207
327
|
const trashParsed = this._parseLockContent(trashContent);
|
|
208
328
|
if ((trashParsed?.uuid ?? '') !== expectedUuid) {
|
|
209
329
|
// Lock changed hands mid-check — restore it and back off.
|
|
210
|
-
|
|
211
|
-
await fs.unlink(trashPath);
|
|
330
|
+
await this._restoreLockFile(trashPath);
|
|
212
331
|
return;
|
|
213
332
|
}
|
|
214
|
-
await fs.unlink(trashPath);
|
|
333
|
+
await fs.unlink(trashPath).catch(() => { /* leaked copy; the sweep above collects it */ });
|
|
215
334
|
logger.log('[FileLockService] Stale lock removed');
|
|
216
|
-
} catch {
|
|
335
|
+
} catch {
|
|
336
|
+
// A failure AFTER the rename means the verify never ran — restore
|
|
337
|
+
// rather than leave the (possibly live) lock destroyed in the trash.
|
|
338
|
+
if (renamed) await this._restoreLockFile(trashPath);
|
|
339
|
+
/* otherwise best-effort startup cleanup; acquire() handles contention */
|
|
340
|
+
}
|
|
217
341
|
}
|
|
218
342
|
} catch (error: unknown) {
|
|
219
343
|
// ENOENT is expected (no lock file exists)
|
|
@@ -232,6 +356,9 @@ export class FileLockService implements IService {
|
|
|
232
356
|
* @throws Error if unable to acquire lock within timeout
|
|
233
357
|
*/
|
|
234
358
|
async acquireLock(): Promise<void> {
|
|
359
|
+
if (this._retired) {
|
|
360
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service has been disposed`);
|
|
361
|
+
}
|
|
235
362
|
const heldLocks = lockContext.getStore();
|
|
236
363
|
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
237
364
|
|
|
@@ -270,6 +397,14 @@ export class FileLockService implements IService {
|
|
|
270
397
|
return;
|
|
271
398
|
}
|
|
272
399
|
|
|
400
|
+
// Re-check after the wait: cleanup() may have retired the instance while we
|
|
401
|
+
// were queued. Hand the turn straight on rather than acquiring a lock file this
|
|
402
|
+
// instance no longer owns.
|
|
403
|
+
if (this._retired) {
|
|
404
|
+
myResolve();
|
|
405
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed while queued`);
|
|
406
|
+
}
|
|
407
|
+
|
|
273
408
|
// Capture resolve function so we can trigger it in releaseLock() or on failure
|
|
274
409
|
this.resolveTurn = myResolve;
|
|
275
410
|
|
|
@@ -282,6 +417,15 @@ export class FileLockService implements IService {
|
|
|
282
417
|
|
|
283
418
|
try {
|
|
284
419
|
for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
|
|
420
|
+
// cleanup() can retire the instance while we are contending here — its
|
|
421
|
+
// drain polls only the HELD handle, so a caller mid-retry (holding
|
|
422
|
+
// nothing yet) is invisible to it and would otherwise win the lock and
|
|
423
|
+
// run its critical section against torn-down services after dispose
|
|
424
|
+
// resolved. The queued-waiter check above cannot catch this: our turn was
|
|
425
|
+
// granted before retirement. Abort; the catch below hands the turn on.
|
|
426
|
+
if (this._retired) {
|
|
427
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
|
|
428
|
+
}
|
|
285
429
|
// Use a LOCAL handle variable so that concurrent acquireLock() calls on
|
|
286
430
|
// the same instance cannot close each other's file handle through the
|
|
287
431
|
// shared this.lockHandle field. We only promote to this.lockHandle after
|
|
@@ -293,6 +437,14 @@ export class FileLockService implements IService {
|
|
|
293
437
|
await handle.write(this._makeLockContent());
|
|
294
438
|
await handle.sync(); // Ensure content is written to disk
|
|
295
439
|
|
|
440
|
+
// Retirement may have landed between the open above and this commit.
|
|
441
|
+
// cleanup()'s drain saw no held handle and already returned, so nothing
|
|
442
|
+
// would ever tear this acquisition down. Throwing here lets the catch
|
|
443
|
+
// below close the handle and remove the just-created lock file.
|
|
444
|
+
if (this._retired) {
|
|
445
|
+
throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
|
|
446
|
+
}
|
|
447
|
+
|
|
296
448
|
// Fully acquired — commit to instance state
|
|
297
449
|
this.lockHandle = handle;
|
|
298
450
|
this.lockCount = 1;
|
|
@@ -355,26 +507,47 @@ export class FileLockService implements IService {
|
|
|
355
507
|
// surrounding retry loop / timeout, not by stealing.
|
|
356
508
|
if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
|
|
357
509
|
const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
|
|
510
|
+
let renamed = false;
|
|
358
511
|
try {
|
|
359
512
|
await fs.rename(this.lockFilePath, trashPath);
|
|
513
|
+
renamed = true;
|
|
360
514
|
const trashContent = await fs.readFile(trashPath, 'utf-8');
|
|
361
515
|
const trashParsed = this._parseLockContent(trashContent);
|
|
362
516
|
if ((trashParsed?.uuid ?? '') !== lockUuid) {
|
|
363
|
-
|
|
364
|
-
await fs.unlink(trashPath);
|
|
517
|
+
await this._restoreLockFile(trashPath);
|
|
365
518
|
continue;
|
|
366
519
|
}
|
|
367
|
-
await fs.unlink(trashPath);
|
|
368
|
-
} catch {
|
|
520
|
+
await fs.unlink(trashPath).catch(() => { /* leaked copy; startup sweep collects it */ });
|
|
521
|
+
} catch {
|
|
522
|
+
// Rename failed (nothing moved) — retry handles it. But a read
|
|
523
|
+
// failure AFTER the rename means the verify never ran: the lock
|
|
524
|
+
// may have changed hands to a live peer mid-check, and leaving
|
|
525
|
+
// it in the trash destroys it unverified — the same transient
|
|
526
|
+
// classes (EMFILE, EACCES, EBUSY) the inspect branch below
|
|
527
|
+
// backs off for. Put it back instead.
|
|
528
|
+
if (renamed) await this._restoreLockFile(trashPath);
|
|
529
|
+
}
|
|
369
530
|
continue;
|
|
370
531
|
}
|
|
371
|
-
} catch (
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
532
|
+
} catch (inspectError) {
|
|
533
|
+
// We could not READ the lock, which says nothing about whether its
|
|
534
|
+
// owner is alive. Destroying it here — as this branch used to,
|
|
535
|
+
// with neither a UUID check nor a liveness check, unlike the
|
|
536
|
+
// verified path above — turns any transient fs failure (EMFILE
|
|
537
|
+
// under fd pressure, EACCES, EBUSY on Windows) into a stolen lock
|
|
538
|
+
// and two concurrent writers on the same file.
|
|
539
|
+
//
|
|
540
|
+
// ENOENT is the exception and the common case: the lock was
|
|
541
|
+
// released between our open() and our read, so there is nothing to
|
|
542
|
+
// destroy and retrying immediately is correct.
|
|
543
|
+
const code = (inspectError as NodeJS.ErrnoException | undefined)?.code;
|
|
544
|
+
if (code === 'ENOENT') continue;
|
|
545
|
+
logger.warn(
|
|
546
|
+
`[FileLockService] Could not inspect contended lock at ${this.lockFilePath} ` +
|
|
547
|
+
`(${code ?? 'unknown'}); backing off rather than reclaiming it.`,
|
|
548
|
+
);
|
|
549
|
+
metrics.increment('state_lock_inspect_failed_total', 1, { code: code ?? 'unknown' });
|
|
550
|
+
// Fall through to the timeout check + backoff below.
|
|
378
551
|
}
|
|
379
552
|
|
|
380
553
|
if (Date.now() - startTime >= this.lockTimeout) {
|
|
@@ -400,10 +573,7 @@ export class FileLockService implements IService {
|
|
|
400
573
|
} catch (err) {
|
|
401
574
|
// If acquisition failed (timeout/error), we MUST release our turn in the
|
|
402
575
|
// queue so the next caller can try.
|
|
403
|
-
|
|
404
|
-
this.resolveTurn();
|
|
405
|
-
this.resolveTurn = null;
|
|
406
|
-
}
|
|
576
|
+
this._releaseTurn();
|
|
407
577
|
throw err;
|
|
408
578
|
}
|
|
409
579
|
}
|
|
@@ -413,6 +583,11 @@ export class FileLockService implements IService {
|
|
|
413
583
|
*/
|
|
414
584
|
async releaseLock(): Promise<void> {
|
|
415
585
|
if (this.lockHandle === null) {
|
|
586
|
+
// cleanup() can tear the handle down underneath an in-flight critical
|
|
587
|
+
// section, so withLock()'s finally lands here with the handle already gone.
|
|
588
|
+
// The queue turn is still ours to hand back — returning without doing so
|
|
589
|
+
// leaves every queued waiter blocked on `await previous` for good.
|
|
590
|
+
this._releaseTurn();
|
|
416
591
|
return;
|
|
417
592
|
}
|
|
418
593
|
|
|
@@ -443,11 +618,7 @@ export class FileLockService implements IService {
|
|
|
443
618
|
metrics.setGauge('state_lock_held', 0);
|
|
444
619
|
} finally {
|
|
445
620
|
// 4. Signal the next caller in the queue that it's their turn.
|
|
446
|
-
|
|
447
|
-
this.resolveTurn = null;
|
|
448
|
-
if (resolve) {
|
|
449
|
-
resolve();
|
|
450
|
-
}
|
|
621
|
+
this._releaseTurn();
|
|
451
622
|
}
|
|
452
623
|
}
|
|
453
624
|
|
|
@@ -455,8 +626,13 @@ export class FileLockService implements IService {
|
|
|
455
626
|
* Execute a function within the scope of a filesystem lock.
|
|
456
627
|
* This is the preferred way to use the lock service.
|
|
457
628
|
* Safe re-entrancy is supported within the same async flow.
|
|
629
|
+
*
|
|
630
|
+
* The acquire timeout is the one given to the constructor (`lockTimeout`). This
|
|
631
|
+
* used to take a second `_timeout` argument that was accepted and then ignored
|
|
632
|
+
* entirely — a caller passing 5000 silently got the constructed 20000 — so it is
|
|
633
|
+
* gone rather than left to mislead. No caller was passing it.
|
|
458
634
|
*/
|
|
459
|
-
async withLock<T>(callback: () => Promise<T> | T
|
|
635
|
+
async withLock<T>(callback: () => Promise<T> | T): Promise<T> {
|
|
460
636
|
const heldLocks = lockContext.getStore();
|
|
461
637
|
const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
|
|
462
638
|
|
|
@@ -477,9 +653,28 @@ export class FileLockService implements IService {
|
|
|
477
653
|
}
|
|
478
654
|
|
|
479
655
|
/**
|
|
480
|
-
*
|
|
656
|
+
* Clean up any lock file owned by this instance, and retire the instance.
|
|
657
|
+
*
|
|
658
|
+
* Called only from shutdown paths (`dispose`, `StateManager.cleanup`). It first
|
|
659
|
+
* gives an in-flight critical section a bounded chance to release normally, then
|
|
660
|
+
* forces the teardown if it has not. After this the instance is retired:
|
|
661
|
+
* `acquireLock` fails fast rather than blocking on a queue nothing will ever
|
|
662
|
+
* drain, which is what makes forcing the teardown safe to do at all.
|
|
481
663
|
*/
|
|
482
664
|
async cleanup(): Promise<void> {
|
|
665
|
+
const drained = await this._drainHeldLock(CLEANUP_DRAIN_TIMEOUT_MS);
|
|
666
|
+
if (!drained) {
|
|
667
|
+
logger.warn(
|
|
668
|
+
`[FileLockService] Lock at ${this.lockFilePath} still held after ${CLEANUP_DRAIN_TIMEOUT_MS}ms; forcing teardown. ` +
|
|
669
|
+
'A concurrent writer in another process may observe the lock as free.',
|
|
670
|
+
);
|
|
671
|
+
metrics.increment('state_lock_forced_teardown_total', 1);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Retire BEFORE releasing the turn, so a waiter woken below re-checks and
|
|
675
|
+
// fails fast instead of acquiring a lock this instance no longer manages.
|
|
676
|
+
this._retired = true;
|
|
677
|
+
|
|
483
678
|
if (this.lockHandle) {
|
|
484
679
|
try {
|
|
485
680
|
const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
|
|
@@ -494,6 +689,8 @@ export class FileLockService implements IService {
|
|
|
494
689
|
this.lockHandle = null;
|
|
495
690
|
}
|
|
496
691
|
this.lockCount = 0;
|
|
692
|
+
// Unblock anyone parked on `await previous`; they will now see _retired and throw.
|
|
693
|
+
this._releaseTurn();
|
|
497
694
|
}
|
|
498
695
|
|
|
499
696
|
/**
|
|
@@ -11,6 +11,39 @@ import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts
|
|
|
11
11
|
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
12
12
|
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Parse the POSIX `ps -o etime=` elapsed-time format into whole seconds.
|
|
16
|
+
* Accepted shapes: `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`. Returns null if it does
|
|
17
|
+
* not parse, so a caller degrades to "unknown" rather than to a bogus number.
|
|
18
|
+
*/
|
|
19
|
+
export function parseEtime(value: string): number | null {
|
|
20
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
|
|
21
|
+
if (!m) return null;
|
|
22
|
+
const days = m[1] ? parseInt(m[1], 10) : 0;
|
|
23
|
+
const hours = m[2] ? parseInt(m[2], 10) : 0;
|
|
24
|
+
const minutes = parseInt(m[3]!, 10);
|
|
25
|
+
const seconds = parseInt(m[4]!, 10);
|
|
26
|
+
if ([days, hours, minutes, seconds].some((n) => isNaN(n))) return null;
|
|
27
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Does this `process.kill(pid, 0)` failure mean the process is GONE?
|
|
32
|
+
*
|
|
33
|
+
* Only `ESRCH` does. `EPERM` (POSIX) and `EACCES` — which is what libuv surfaces
|
|
34
|
+
* on Windows when `OpenProcess` returns ERROR_ACCESS_DENIED — both mean the
|
|
35
|
+
* process EXISTS but is not ours to signal: another user on a shared host, a CI
|
|
36
|
+
* side-car, a different Windows session. Reporting those as dead lets a caller
|
|
37
|
+
* reclaim a live lock, run slot or leader out from under its owner, which is how
|
|
38
|
+
* two writers end up racing the state file. The orphan guard and the browser
|
|
39
|
+
* sweep already get this right; this is the same rule.
|
|
40
|
+
*/
|
|
41
|
+
function isDefinitelyGone(err: unknown): boolean {
|
|
42
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
|
43
|
+
if (code === 'EPERM' || code === 'EACCES') return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
14
47
|
/**
|
|
15
48
|
* Process Lifecycle Service
|
|
16
49
|
*
|
|
@@ -74,23 +107,51 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
74
107
|
}
|
|
75
108
|
}
|
|
76
109
|
|
|
77
|
-
// Cross-platform fallback (macOS/
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
// Cross-platform fallback (macOS/BSD): derive start time from elapsed time.
|
|
111
|
+
//
|
|
112
|
+
// `etimes` (elapsed WHOLE SECONDS) is a procps-ng/FreeBSD extension. Apple's
|
|
113
|
+
// ps (adv_cmds) does not implement it — it has only the POSIX `etime`
|
|
114
|
+
// ([[dd-]hh:]mm:ss). Asking for an unsupported keyword does not fail loudly:
|
|
115
|
+
// procps-ng writes an error to stderr and still exits 0 with EMPTY stdout,
|
|
116
|
+
// BSD ps exits non-zero. Both collapse to `null` here, which meant every
|
|
117
|
+
// PID+start-time guard on macOS silently degraded to a bare signal-0 check —
|
|
118
|
+
// the file lock, the run-slot semaphore, the GPU lock and both leader
|
|
119
|
+
// elections all advertise PID-reuse safety they were not actually getting.
|
|
120
|
+
//
|
|
121
|
+
// So: try `etimes`, and fall back to parsing POSIX `etime` when it yields
|
|
122
|
+
// nothing. `etime` is mandated by POSIX, so the fallback works everywhere.
|
|
123
|
+
const elapsedSec = await this.readElapsedSeconds(pid);
|
|
124
|
+
if (elapsedSec === null) return null;
|
|
125
|
+
return Math.floor(Date.now() / 1000) - elapsedSec;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
|
|
129
|
+
private async readElapsedSeconds(pid: number): Promise<number | null> {
|
|
130
|
+
const run = async (fmt: string): Promise<string | null> => {
|
|
131
|
+
try {
|
|
132
|
+
return await new Promise<string>((resolve, reject) => {
|
|
133
|
+
const child = execFile(
|
|
134
|
+
'ps', ['-o', fmt, '-p', String(pid)],
|
|
135
|
+
{ encoding: 'utf8', timeout: 3000 },
|
|
136
|
+
(err, stdout) => err ? reject(err) : resolve(stdout),
|
|
137
|
+
);
|
|
138
|
+
child.unref?.();
|
|
139
|
+
});
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const etimes = await run('etimes=');
|
|
146
|
+
if (etimes && etimes.trim()) {
|
|
147
|
+
const n = parseInt(etimes.trim(), 10);
|
|
148
|
+
if (!isNaN(n)) return n;
|
|
93
149
|
}
|
|
150
|
+
|
|
151
|
+
const etime = await run('etime=');
|
|
152
|
+
if (etime && etime.trim()) return parseEtime(etime.trim());
|
|
153
|
+
|
|
154
|
+
return null;
|
|
94
155
|
}
|
|
95
156
|
|
|
96
157
|
/**
|
|
@@ -111,11 +172,20 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
111
172
|
* @returns true if process is alive (and matches start time if provided)
|
|
112
173
|
*/
|
|
113
174
|
async isProcessAlive(pid: number, expectedStartTime?: number | null): Promise<boolean> {
|
|
175
|
+
// 1. EXISTENCE. Separate from identity (step 2) on purpose: a permission error
|
|
176
|
+
// means the process exists but is not ours to signal, which must NOT
|
|
177
|
+
// short-circuit the start-time comparison — a PID-reuse impostor owned by
|
|
178
|
+
// another user would otherwise read as a live owner and deadlock the lock,
|
|
179
|
+
// run slot or leadership it forged.
|
|
114
180
|
try {
|
|
115
|
-
// 1. Basic liveness check via signal 0
|
|
116
181
|
process.kill(pid, 0);
|
|
117
|
-
|
|
118
|
-
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (isDefinitelyGone(err)) return false;
|
|
184
|
+
// EPERM/EACCES: exists, not signalable by us — continue to the identity check.
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
// 2. IDENTITY. If start time verification requested, check it.
|
|
119
189
|
if (expectedStartTime !== undefined && expectedStartTime !== null) {
|
|
120
190
|
const actualStartTime = await this.getProcessStartTime(pid);
|
|
121
191
|
if (actualStartTime === null) {
|
|
@@ -132,14 +202,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
132
202
|
// caller — a live lock/leader/run-cap owner is reclaimed from under it
|
|
133
203
|
// → two writers collide (lost update) or the run-cap admits extra
|
|
134
204
|
// concurrent runs (the flaky duplicate-slot/cap-breach we saw on Windows
|
|
135
|
-
// CI). So re-confirm
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
205
|
+
// CI). So re-confirm existence instead of guessing: if the PID is STILL
|
|
206
|
+
// there the process is alive → true; if it has gone it died between the
|
|
207
|
+
// two probes → false. A genuine death is therefore still detected within
|
|
208
|
+
// one poll tick (~100ms); the only behavioural change is that a
|
|
209
|
+
// confirmed-alive PID is never again misreported as dead on a transient
|
|
210
|
+
// start-time lookup failure.
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (reconfirmErr) {
|
|
215
|
+
return !isDefinitelyGone(reconfirmErr);
|
|
216
|
+
}
|
|
143
217
|
}
|
|
144
218
|
// Allow ±1s slack. The macOS/BSD fallback derives start time as
|
|
145
219
|
// floor(Date.now()/1000) - `ps etimes` (elapsed whole seconds); both terms are
|
|
@@ -149,10 +223,13 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
149
223
|
// within a 1s window at the identical PID is not a realistic false-match.
|
|
150
224
|
return Math.abs(actualStartTime - expectedStartTime) <= 1;
|
|
151
225
|
}
|
|
152
|
-
|
|
226
|
+
|
|
153
227
|
return true;
|
|
154
228
|
} catch {
|
|
155
|
-
|
|
229
|
+
// The start-time lookup itself blew up (not a signal error — those are
|
|
230
|
+
// handled above). Existence was already established, so treat it as alive:
|
|
231
|
+
// reclaiming a confirmed-present owner is the worse failure.
|
|
232
|
+
return true;
|
|
156
233
|
}
|
|
157
234
|
}
|
|
158
235
|
|
|
@@ -163,8 +240,8 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
163
240
|
try {
|
|
164
241
|
process.kill(pid, 0);
|
|
165
242
|
return true;
|
|
166
|
-
} catch {
|
|
167
|
-
return
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return !isDefinitelyGone(err);
|
|
168
245
|
}
|
|
169
246
|
}
|
|
170
247
|
|
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
|
|
|
8
8
|
import * as crypto from 'node:crypto';
|
|
9
9
|
import * as path from 'node:path';
|
|
10
10
|
import { logger } from '../../logger.ts';
|
|
11
|
+
import { replaceFile } from '../../utils/atomic-replace.ts';
|
|
11
12
|
import type { IService } from '../../core/service-registry.ts';
|
|
12
13
|
import { ServiceLifecycle } from '../../core/service-registry.ts';
|
|
13
14
|
import { ServiceNames } from '../../core/interfaces/service-names.ts';
|
|
@@ -253,15 +254,12 @@ export class StateBackupManager implements IService {
|
|
|
253
254
|
await fh.close();
|
|
254
255
|
}
|
|
255
256
|
try {
|
|
256
|
-
await
|
|
257
|
-
} catch (renameErr) {
|
|
258
|
-
if (process.platform === 'win32') {
|
|
259
|
-
await fs.copyFile(tempPath, this.stateFilePath);
|
|
260
|
-
await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
|
|
261
|
-
} else {
|
|
257
|
+
if (await replaceFile(tempPath, this.stateFilePath) === 'copied') {
|
|
262
258
|
await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
|
|
263
|
-
throw renameErr;
|
|
264
259
|
}
|
|
260
|
+
} catch (renameErr) {
|
|
261
|
+
await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
|
|
262
|
+
throw renameErr;
|
|
265
263
|
}
|
|
266
264
|
}
|
|
267
265
|
|
|
@@ -195,7 +195,7 @@ export class StateManagerService implements IStateManager {
|
|
|
195
195
|
/**
|
|
196
196
|
* Get the current embedding server information
|
|
197
197
|
*/
|
|
198
|
-
async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string } | null> {
|
|
198
|
+
async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string; authSecret?: string } | null> {
|
|
199
199
|
return this.getStateManager().getEmbeddingServer();
|
|
200
200
|
}
|
|
201
201
|
|
|
@@ -23,6 +23,7 @@ import * as crypto from 'node:crypto';
|
|
|
23
23
|
import process from 'node:process';
|
|
24
24
|
import { getGlobalConfigDir } from '../../config.ts';
|
|
25
25
|
import { logger } from '../../logger.ts';
|
|
26
|
+
import { replaceFile } from '../../utils/atomic-replace.ts';
|
|
26
27
|
import { metrics } from '../../utils/metrics.ts';
|
|
27
28
|
import type { IProcessLifecycle } from '../../core/interfaces/process-interfaces.ts';
|
|
28
29
|
import type {
|
|
@@ -350,25 +351,23 @@ export class StateManager {
|
|
|
350
351
|
} finally {
|
|
351
352
|
await fh.close();
|
|
352
353
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
370
|
-
throw renameErr;
|
|
371
|
-
}
|
|
354
|
+
// Atomic where the platform allows; on Windows a third-party lock on the target
|
|
355
|
+
// is retried before conceding to a non-atomic copy (see replaceFile).
|
|
356
|
+
const outcome = await replaceFile(tempFilePath, this.stateFilePath);
|
|
357
|
+
if (outcome === 'copied') {
|
|
358
|
+
logger.warn(
|
|
359
|
+
'[StateManager] State written via non-atomic copy: the rename was blocked by another ' +
|
|
360
|
+
'process holding the state file. A concurrent reader may observe a partial file.',
|
|
361
|
+
);
|
|
362
|
+
metrics.increment('state_write_non_atomic_total', 1);
|
|
363
|
+
// The state IS persisted, so removing the temp is best-effort: a transient AV lock
|
|
364
|
+
// on the temp would otherwise throw and report "Failed to write state" for a write
|
|
365
|
+
// that actually succeeded — a caller retrying or aborting on that would act wrongly.
|
|
366
|
+
const tmp = tempFilePath;
|
|
367
|
+
tempFilePath = null;
|
|
368
|
+
await fs.unlink(tmp).catch((err) => {
|
|
369
|
+
logger.warn('[StateManager] Failed to remove temp file after copy fallback (write succeeded):', err);
|
|
370
|
+
});
|
|
372
371
|
}
|
|
373
372
|
tempFilePath = null;
|
|
374
373
|
await this.backupManager.cleanupOldBackups();
|
|
@@ -453,7 +452,7 @@ export class StateManager {
|
|
|
453
452
|
|
|
454
453
|
// ==================== Embedding Server API ====================
|
|
455
454
|
|
|
456
|
-
public async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string } | null> {
|
|
455
|
+
public async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string; authSecret?: string } | null> {
|
|
457
456
|
const state = await this.readState();
|
|
458
457
|
return state.embeddingServer ?? null;
|
|
459
458
|
}
|