@openparachute/hub 0.6.5-rc.6 → 0.6.5-rc.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.5-rc.6",
3
+ "version": "0.6.5-rc.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-token.ts";
6
6
  import {
7
+ armServeDbWatchdog,
7
8
  formatBootstrapTokenBanner,
8
9
  formatListeningBanner,
9
10
  hubPortConflictMessage,
@@ -463,3 +464,74 @@ describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
463
464
  expect(resolveStartupIssuer({}, {}, throwing)).toBeUndefined();
464
465
  });
465
466
  });
467
+
468
+ describe("armServeDbWatchdog — #610/#619 ghost-fd watchdog wiring on the serve path", () => {
469
+ let tmp: string;
470
+ let realDbPath: string;
471
+
472
+ beforeEach(() => {
473
+ tmp = mkdtempSync(join(tmpdir(), "serve-watchdog-"));
474
+ realDbPath = join(tmp, "hub.db");
475
+ });
476
+ afterEach(() => {
477
+ rmSync(tmp, { recursive: true, force: true });
478
+ });
479
+
480
+ test("starts the liveness timer (without it, a wipe is never noticed)", () => {
481
+ let tick: (() => void) | undefined;
482
+ const { livenessTimer } = armServeDbWatchdog(realDbPath, {
483
+ openDb: () => openHubDb(realDbPath),
484
+ statInode: () => ({ dev: 1, ino: 42 }),
485
+ setIntervalFn: (cb) => {
486
+ tick = cb;
487
+ return 0;
488
+ },
489
+ clearIntervalFn: () => {},
490
+ });
491
+ // The timer must actually be armed — the captured tick callback proves
492
+ // startDbPathLivenessTimer ran (the #619 bug was that it never did on this path).
493
+ expect(tick).toBeInstanceOf(Function);
494
+ expect(livenessTimer).toBeDefined();
495
+ });
496
+
497
+ test("opens the db BEFORE snapshotting the inode, so a wipe tick self-exits (#619 ordering)", () => {
498
+ // The load-bearing invariant: `initialInode` must be a DEFINED baseline so
499
+ // a later "gone" verdict fires reopen-or-exit. If the helper statted before
500
+ // opening (the bug), a fresh path would yield ENOENT → undefined baseline →
501
+ // probe stuck at "unknown" → NEVER exits on a wipe.
502
+ let opened = false;
503
+ let wiped = false;
504
+ let tick: (() => void) | undefined;
505
+ const exitCodes: number[] = [];
506
+ armServeDbWatchdog(realDbPath, {
507
+ openDb: () => {
508
+ if (wiped) throw new Error("ENOENT: state dir wiped");
509
+ opened = true;
510
+ return openHubDb(realDbPath);
511
+ },
512
+ statInode: () => {
513
+ if (wiped) return undefined; // path gone
514
+ // Proves ordering: if the helper statted before opening, this throws and
515
+ // the helper's catch leaves initialInode undefined (watchdog disabled).
516
+ if (!opened) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
517
+ return { dev: 1, ino: 42 };
518
+ },
519
+ setIntervalFn: (cb) => {
520
+ tick = cb;
521
+ return 0;
522
+ },
523
+ clearIntervalFn: () => {},
524
+ exit: (code) => exitCodes.push(code),
525
+ });
526
+
527
+ // Simulate the wipe, then drive one watchdog tick.
528
+ wiped = true;
529
+ expect(tick).toBeInstanceOf(Function);
530
+ tick?.();
531
+
532
+ // The probe saw "gone" against a real baseline → reopen threw (dir gone) →
533
+ // exit(1). A non-zero exitCodes proves `initialInode` was a defined baseline,
534
+ // which proves the db was opened before the inode snapshot.
535
+ expect(exitCodes).toEqual([1]);
536
+ });
537
+ });
@@ -34,7 +34,7 @@ import { generateBootstrapToken } from "../bootstrap-token.ts";
34
34
  // path isolation.
35
35
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
36
36
  import { readExposeState } from "../expose-state.ts";
37
- import { createDbHolder } from "../hub-db-liveness.ts";
37
+ import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
38
38
  import { hubDbPath, openHubDb } from "../hub-db.ts";
39
39
  import { hubFetch } from "../hub-server.ts";
40
40
  import { writeHubFile } from "../hub.ts";
@@ -304,6 +304,80 @@ export function formatBootstrapTokenBanner(token: string, hubUrl?: string): stri
304
304
  ].join("\n");
305
305
  }
306
306
 
307
+ /**
308
+ * Injectable seams for {@link armServeDbWatchdog} (test-only). Generic on the
309
+ * timer handle `H` so the scheduler seams never name `setInterval` in type
310
+ * position — mirrors `DbLivenessTimerDeps<H>` in hub-db-liveness.ts, which
311
+ * keeps the public interface portable to a types-less tsc environment.
312
+ */
313
+ export interface ServeDbWatchdogDeps<H = unknown> {
314
+ log?: (line: string) => void;
315
+ /** Open a db handle (default {@link openHubDb}). Tests inject a fake that creates a fixture. */
316
+ openDb?: (path: string) => ReturnType<typeof openHubDb>;
317
+ /** Path stat for the inode snapshot + proactive probe (default {@link defaultStatInode}). */
318
+ statInode?: typeof defaultStatInode;
319
+ /** Injectable scheduler threaded to the liveness timer (default `setInterval`). */
320
+ setIntervalFn?: (cb: () => void, ms: number) => H;
321
+ /** Injectable clear threaded to the liveness timer (default `clearInterval`). */
322
+ clearIntervalFn?: (handle: H) => void;
323
+ /** Process-exit fn threaded into the holder's reopen-or-exit (default `process.exit`). */
324
+ exit?: (code: number) => void;
325
+ }
326
+
327
+ /**
328
+ * Build the self-heal DB holder (#594) + start the proactive ghost-fd watchdog
329
+ * (#610) for the `parachute serve` path, returning both so the caller wires
330
+ * `getDb`/`onDbError`/`probeDbPath` and stops the timer on shutdown.
331
+ *
332
+ * Extracted + exported so the wiring is unit-testable WITHOUT binding a real
333
+ * port (#619): a serve()-level test would have to `Bun.serve` and risk the
334
+ * hub#535 launchd-bootout hazard, so this pure helper carries the load-bearing
335
+ * invariants instead — (1) the db is OPENED before the inode is snapshotted, so
336
+ * a fresh-install first boot gets a defined baseline (an ENOENT snapshot would
337
+ * silently disable the proactive probe for the whole process lifetime), and
338
+ * (2) the liveness timer is actually started. Both were absent on this path
339
+ * before #619 — the watchdog was wired only into `createHubServer`.
340
+ */
341
+ export function armServeDbWatchdog<H = unknown>(
342
+ dbPath: string,
343
+ deps: ServeDbWatchdogDeps<H> = {},
344
+ ): {
345
+ dbHolder: ReturnType<typeof createDbHolder>;
346
+ livenessTimer: ReturnType<typeof startDbPathLivenessTimer>;
347
+ } {
348
+ const openDb = deps.openDb ?? openHubDb;
349
+ const statInode = deps.statInode ?? defaultStatInode;
350
+ // Open FIRST — `openHubDb` mkdir's + creates the file when absent, so the
351
+ // stat below sees a real inode on a fresh-install first boot. Reversing this
352
+ // would leave `initialInode` undefined (ENOENT) and the probe at "unknown"
353
+ // for the process lifetime. Mirrors `createHubServer`'s ordering.
354
+ const db = openDb(dbPath);
355
+ let initialInode: ReturnType<typeof defaultStatInode> | undefined;
356
+ try {
357
+ initialInode = statInode(dbPath);
358
+ } catch {
359
+ initialInode = undefined;
360
+ }
361
+ const dbHolder = createDbHolder(db, {
362
+ reopen: () => openDb(dbPath),
363
+ dbPath,
364
+ statInode,
365
+ initialInode,
366
+ ...(deps.log !== undefined ? { log: deps.log } : {}),
367
+ ...(deps.exit !== undefined ? { exit: deps.exit } : {}),
368
+ });
369
+ // The active `parachute serve` path (systemd / launchd / container ExecStart)
370
+ // MUST start the watchdog here, not only in `createHubServer` — else a
371
+ // `rm -rf ~/.parachute` under a running unit leaves a ghost fd that keeps
372
+ // SELECT 1 succeeding with no thrown error, the reactive path never fires,
373
+ // and the hub never self-recovers (#619).
374
+ const livenessTimer = startDbPathLivenessTimer<H>(dbHolder, {
375
+ ...(deps.setIntervalFn !== undefined ? { setIntervalFn: deps.setIntervalFn } : {}),
376
+ ...(deps.clearIntervalFn !== undefined ? { clearIntervalFn: deps.clearIntervalFn } : {}),
377
+ });
378
+ return { dbHolder, livenessTimer };
379
+ }
380
+
307
381
  /**
308
382
  * Run the hub fetch loop in the foreground. Resolves when `Bun.serve` is
309
383
  * bound; the returned `stop()` shuts the server down for tests.
@@ -355,15 +429,8 @@ export async function serve(opts: ServeOpts = {}): Promise<{
355
429
  writeHubFile(hubHtmlPath);
356
430
 
357
431
  const dbPath = hubDbPath();
358
- // Self-heal-or-die DB holder (#594). The handle lives behind a mutable
359
- // holder so a request that hits the persistent-corruption class (disk I/O
360
- // error / malformed image — e.g. the state dir deleted under a running hub)
361
- // can reopen the handle once, or exit(1) for the platform manager to restart
362
- // us with a fresh one. `getDb` reads the current handle from the holder.
363
- const dbHolder = createDbHolder(openHubDb(dbPath), {
364
- reopen: () => openHubDb(dbPath),
365
- log,
366
- });
432
+ // Self-heal-or-die DB holder (#594) + proactive ghost-fd watchdog (#610/#619).
433
+ const { dbHolder, livenessTimer } = armServeDbWatchdog(dbPath, { log });
367
434
  const adminBootstrap = await seedInitialAdminIfNeeded(dbHolder.get(), env, log);
368
435
 
369
436
  if (adminBootstrap === "needs-setup") {
@@ -401,6 +468,9 @@ export async function serve(opts: ServeOpts = {}): Promise<{
401
468
  fetch: hubFetch(WELL_KNOWN_DIR, {
402
469
  getDb: () => dbHolder.get(),
403
470
  onDbError: (err) => dbHolder.healOrExit(err),
471
+ // #610: /health's db check probes the path so monitoring + the #591
472
+ // adoption probe see a wipe instead of the ghost-fd lie.
473
+ probeDbPath: () => dbHolder.probePath(),
404
474
  issuer,
405
475
  loopbackPort: port,
406
476
  supervisor,
@@ -486,6 +556,7 @@ export async function serve(opts: ServeOpts = {}): Promise<{
486
556
  for (const state of supervisor.list()) {
487
557
  await supervisor.stop(state.short);
488
558
  }
559
+ livenessTimer.stop();
489
560
  await server.stop();
490
561
  dbHolder.get().close();
491
562
  },