@openparachute/hub 0.6.4-rc.9 → 0.6.5-rc.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.6.4-rc.9",
3
+ "version": "0.6.5-rc.1",
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": {
@@ -3,9 +3,11 @@ import {
3
3
  CloudflaredError,
4
4
  createTunnel,
5
5
  credentialsPath,
6
+ deleteTunnel,
6
7
  findTunnelByName,
7
8
  listTunnels,
8
9
  routeDns,
10
+ tunnelConnectionCount,
9
11
  } from "../cloudflare/tunnel.ts";
10
12
  import type { CommandResult, Runner } from "../tailscale/run.ts";
11
13
 
@@ -204,4 +206,80 @@ describe("cloudflare tunnel", () => {
204
206
  test("credentialsPath joins uuid under the cloudflared home", () => {
205
207
  expect(credentialsPath("abc", "/Users/x/.cloudflared")).toBe("/Users/x/.cloudflared/abc.json");
206
208
  });
209
+
210
+ test("deleteTunnel passes --force and surfaces failures (#593)", async () => {
211
+ const { runner, seen } = makeRunner(
212
+ [["cloudflared", "tunnel", "delete", "--force", "parachute"]],
213
+ [{ code: 0, stdout: "Deleted tunnel parachute\n", stderr: "" }],
214
+ );
215
+ await deleteTunnel(runner, "parachute");
216
+ expect(seen[0]).toEqual(["cloudflared", "tunnel", "delete", "--force", "parachute"]);
217
+
218
+ const fail = makeRunner(
219
+ [["cloudflared", "tunnel", "delete", "--force", "parachute"]],
220
+ [{ code: 1, stdout: "", stderr: "tunnel has active connections" }],
221
+ );
222
+ await expect(deleteTunnel(fail.runner, "parachute")).rejects.toMatchObject({
223
+ message: expect.stringContaining("active connections"),
224
+ });
225
+ });
226
+
227
+ describe("tunnelConnectionCount (#593)", () => {
228
+ function infoRunner(result: CommandResult): Runner {
229
+ return async (cmd) => {
230
+ expect([...cmd]).toEqual([
231
+ "cloudflared",
232
+ "tunnel",
233
+ "info",
234
+ "--output",
235
+ "json",
236
+ "parachute",
237
+ ]);
238
+ return result;
239
+ };
240
+ }
241
+
242
+ test("counts connector entries under `conns`", async () => {
243
+ const runner = infoRunner({
244
+ code: 0,
245
+ stdout: JSON.stringify({ conns: [{ id: "a" }, { id: "b" }] }),
246
+ stderr: "",
247
+ });
248
+ expect(await tunnelConnectionCount(runner, "parachute")).toBe(2);
249
+ });
250
+
251
+ test("counts connector entries under the legacy `connections` shape", async () => {
252
+ const runner = infoRunner({
253
+ code: 0,
254
+ stdout: JSON.stringify({ connections: [{ id: "a" }] }),
255
+ stderr: "",
256
+ });
257
+ expect(await tunnelConnectionCount(runner, "parachute")).toBe(1);
258
+ });
259
+
260
+ test("returns 0 on empty conns, non-zero exit, unparseable JSON, or a runner throw", async () => {
261
+ expect(
262
+ await tunnelConnectionCount(
263
+ infoRunner({ code: 0, stdout: '{"conns":[]}', stderr: "" }),
264
+ "parachute",
265
+ ),
266
+ ).toBe(0);
267
+ expect(
268
+ await tunnelConnectionCount(
269
+ infoRunner({ code: 1, stdout: "", stderr: "not found" }),
270
+ "parachute",
271
+ ),
272
+ ).toBe(0);
273
+ expect(
274
+ await tunnelConnectionCount(
275
+ infoRunner({ code: 0, stdout: "not json", stderr: "" }),
276
+ "parachute",
277
+ ),
278
+ ).toBe(0);
279
+ const thrower: Runner = async () => {
280
+ throw new Error("spawn failed");
281
+ };
282
+ expect(await tunnelConnectionCount(thrower, "parachute")).toBe(0);
283
+ });
284
+ });
207
285
  });
@@ -12,6 +12,7 @@ import {
12
12
  } from "../cloudflare/state.ts";
13
13
  import {
14
14
  type CloudflaredSpawner,
15
+ defaultVerifyConnection,
15
16
  exposeCloudflareOff,
16
17
  exposeCloudflareUp,
17
18
  } from "../commands/expose-cloudflare.ts";
@@ -118,6 +119,16 @@ function fakeSpawner(pid: number): { spawner: CloudflaredSpawner; seen: string[]
118
119
  return { spawner, seen };
119
120
  }
120
121
 
122
+ /**
123
+ * Write a fake `~/.cloudflared/<uuid>.json` credentials file so the reuse-path
124
+ * credentials check (#593) sees a healthy local tunnel and reuses it instead of
125
+ * triggering the delete+recreate self-heal. Reuse-path tests that want to
126
+ * exercise the plain reuse behavior call this after `makeEnv()`.
127
+ */
128
+ function seedCreds(env: TestEnv, uuid: string): void {
129
+ writeFileSync(join(env.cloudflaredHome, `${uuid}.json`), "{}");
130
+ }
131
+
121
132
  describe("exposeCloudflareUp", () => {
122
133
  test("happy path: creates tunnel, routes DNS, writes config + state, spawns cloudflared", async () => {
123
134
  const env = makeEnv();
@@ -350,6 +361,7 @@ describe("exposeCloudflareUp", () => {
350
361
  const env = makeEnv();
351
362
  try {
352
363
  const uuid = "bbbbbbbb-0000-0000-0000-000000000002";
364
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
353
365
  const { runner, calls } = queueRunner([
354
366
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
355
367
  {
@@ -563,6 +575,7 @@ describe("exposeCloudflareUp", () => {
563
575
  const env = makeEnv();
564
576
  try {
565
577
  const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
578
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
566
579
  const { runner } = queueRunner([
567
580
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
568
581
  { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
@@ -605,6 +618,8 @@ describe("exposeCloudflareUp", () => {
605
618
  test("stops a prior cloudflared process before spawning a new one", async () => {
606
619
  const env = makeEnv();
607
620
  try {
621
+ // Healthy local creds for the reused tunnel → plain reuse, no recreate (#593).
622
+ seedCreds(env, "cccccccc-0000-0000-0000-000000000003");
608
623
  const priorRecord: CloudflaredTunnelRecord = {
609
624
  pid: 99999,
610
625
  tunnelUuid: "old-tunnel-uuid",
@@ -668,6 +683,7 @@ describe("exposeCloudflareUp", () => {
668
683
  const env = makeEnv();
669
684
  try {
670
685
  const uuid = "cccccccc-0000-0000-0000-000000000003";
686
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
671
687
  const priorRecord: CloudflaredTunnelRecord = {
672
688
  pid: 99999,
673
689
  tunnelUuid: uuid,
@@ -727,6 +743,7 @@ describe("exposeCloudflareUp", () => {
727
743
  const env = makeEnv();
728
744
  try {
729
745
  const uuid = "dddddddd-0000-0000-0000-000000000004";
746
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
730
747
  const { runner } = queueRunner([
731
748
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
732
749
  { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
@@ -774,6 +791,7 @@ describe("exposeCloudflareUp", () => {
774
791
  const env = makeEnv();
775
792
  try {
776
793
  const uuid = "eeeeeeee-0000-0000-0000-000000000006";
794
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
777
795
  const { runner } = queueRunner([
778
796
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
779
797
  { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
@@ -817,6 +835,7 @@ describe("exposeCloudflareUp", () => {
817
835
  const env = makeEnv();
818
836
  try {
819
837
  const uuid = "ffffffff-0000-0000-0000-000000000007";
838
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
820
839
  const { runner } = queueRunner([
821
840
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
822
841
  { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
@@ -1158,6 +1177,7 @@ describe("exposeCloudflareUp", () => {
1158
1177
  const env = makeEnv();
1159
1178
  try {
1160
1179
  const uuid = "bbbb8888-1111-2222-3333-444455556666";
1180
+ seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
1161
1181
  const legacy: CloudflaredTunnelRecord = {
1162
1182
  pid: 71001,
1163
1183
  tunnelUuid: uuid,
@@ -1379,6 +1399,239 @@ describe("exposeCloudflareUp", () => {
1379
1399
  }
1380
1400
  });
1381
1401
  });
1402
+
1403
+ describe("#593: reuse-path credentials self-heal", () => {
1404
+ test("recreates the tunnel when the local credentials file is missing", async () => {
1405
+ const env = makeEnv();
1406
+ try {
1407
+ const staleUuid = "11110000-0000-0000-0000-0000000005aa";
1408
+ const freshUuid = "22220000-0000-0000-0000-0000000005bb";
1409
+ // NO seedCreds → the reused tunnel's local creds file is absent, the
1410
+ // exact field state (account-side tunnel survives, local creds lost).
1411
+ const { runner, calls } = queueRunner([
1412
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
1413
+ {
1414
+ code: 0,
1415
+ stdout: JSON.stringify([{ id: staleUuid, name: "parachute" }]),
1416
+ stderr: "",
1417
+ }, // tunnel list (exists account-side)
1418
+ { code: 0, stdout: "Deleted tunnel parachute\n", stderr: "" }, // tunnel delete --force
1419
+ {
1420
+ code: 0,
1421
+ stdout: `Created tunnel parachute with id ${freshUuid}\n`,
1422
+ stderr: "",
1423
+ }, // tunnel create (fresh creds)
1424
+ { code: 0, stdout: "", stderr: "" }, // route dns
1425
+ ]);
1426
+ const { spawner } = fakeSpawner(43000);
1427
+ const logs: string[] = [];
1428
+
1429
+ const code = await exposeCloudflareUp("vault.example.com", {
1430
+ runner,
1431
+ spawner,
1432
+ alive: () => false,
1433
+ kill: () => {},
1434
+ log: (l) => logs.push(l),
1435
+ manifestPath: env.manifestPath,
1436
+ statePath: env.statePath,
1437
+ exposeStatePath: env.exposeStatePath,
1438
+ configPath: env.configPath,
1439
+ logPath: env.logPath,
1440
+ cloudflaredHome: env.cloudflaredHome,
1441
+ configDir: env.configDir,
1442
+ skipHub: true,
1443
+ tunnelName: "parachute",
1444
+ });
1445
+
1446
+ expect(code).toBe(0);
1447
+ const cmds = calls.map((c) => c.cmd.join(" "));
1448
+ expect(cmds).toContain("cloudflared tunnel delete --force parachute");
1449
+ expect(cmds).toContain("cloudflared tunnel create parachute");
1450
+ // State + config reflect the FRESH uuid, not the stale one.
1451
+ const state = readCloudflaredState(env.statePath);
1452
+ expect(findTunnelRecord(state, "parachute")?.tunnelUuid).toBe(freshUuid);
1453
+ const yaml = readFileSync(env.configPath, "utf8");
1454
+ expect(yaml).toContain(`tunnel: ${freshUuid}`);
1455
+ const joined = logs.join("\n");
1456
+ expect(joined).toContain("local credentials");
1457
+ expect(joined).toContain("Recreated tunnel");
1458
+ } finally {
1459
+ env.cleanup();
1460
+ }
1461
+ });
1462
+
1463
+ test("fails with recovery commands when the stale-tunnel delete itself fails", async () => {
1464
+ const env = makeEnv();
1465
+ try {
1466
+ const staleUuid = "11110000-0000-0000-0000-0000000005cc";
1467
+ const { runner, calls } = queueRunner([
1468
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
1469
+ {
1470
+ code: 0,
1471
+ stdout: JSON.stringify([{ id: staleUuid, name: "parachute" }]),
1472
+ stderr: "",
1473
+ }, // tunnel list
1474
+ { code: 1, stdout: "", stderr: "tunnel has active connections" }, // delete fails
1475
+ ]);
1476
+ const { spawner } = fakeSpawner(43001);
1477
+ const logs: string[] = [];
1478
+
1479
+ const code = await exposeCloudflareUp("vault.example.com", {
1480
+ runner,
1481
+ spawner,
1482
+ alive: () => false,
1483
+ kill: () => {},
1484
+ log: (l) => logs.push(l),
1485
+ manifestPath: env.manifestPath,
1486
+ statePath: env.statePath,
1487
+ exposeStatePath: env.exposeStatePath,
1488
+ configPath: env.configPath,
1489
+ logPath: env.logPath,
1490
+ cloudflaredHome: env.cloudflaredHome,
1491
+ configDir: env.configDir,
1492
+ skipHub: true,
1493
+ tunnelName: "parachute",
1494
+ });
1495
+
1496
+ expect(code).toBe(1);
1497
+ // No connector spawned, no success print.
1498
+ const joined = logs.join("\n");
1499
+ expect(joined).toContain("Couldn't delete the stale tunnel");
1500
+ expect(joined).toContain("cloudflared tunnel delete --force parachute");
1501
+ expect(joined).not.toContain("Cloudflare tunnel up");
1502
+ // create + route never ran.
1503
+ const cmds = calls.map((c) => c.cmd.join(" "));
1504
+ expect(cmds.some((c) => c.startsWith("cloudflared tunnel create"))).toBe(false);
1505
+ } finally {
1506
+ env.cleanup();
1507
+ }
1508
+ });
1509
+ });
1510
+
1511
+ describe("#593: post-start connection verification", () => {
1512
+ test("fails loudly when the connector never registers a connection (timeout)", async () => {
1513
+ const env = makeEnv();
1514
+ try {
1515
+ const uuid = "33330000-0000-0000-0000-0000000005dd";
1516
+ seedCreds(env, uuid);
1517
+ const { runner } = queueRunner([
1518
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1519
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
1520
+ { code: 0, stdout: "", stderr: "" }, // route dns
1521
+ ]);
1522
+ const { spawner } = fakeSpawner(43002);
1523
+ const logs: string[] = [];
1524
+
1525
+ const code = await exposeCloudflareUp("vault.example.com", {
1526
+ runner,
1527
+ spawner,
1528
+ alive: () => false,
1529
+ kill: () => {},
1530
+ log: (l) => logs.push(l),
1531
+ manifestPath: env.manifestPath,
1532
+ statePath: env.statePath,
1533
+ exposeStatePath: env.exposeStatePath,
1534
+ configPath: env.configPath,
1535
+ logPath: env.logPath,
1536
+ cloudflaredHome: env.cloudflaredHome,
1537
+ configDir: env.configDir,
1538
+ skipHub: true,
1539
+ tunnelName: "parachute",
1540
+ // Drive the timeout branch directly (no real cloudflared/poll).
1541
+ verifyConnection: async () => false,
1542
+ });
1543
+
1544
+ expect(code).toBe(1);
1545
+ const joined = logs.join("\n");
1546
+ expect(joined).toContain("never registered a tunnel connection");
1547
+ expect(joined).toContain("error 1033");
1548
+ expect(joined).toContain(env.logPath); // names the connector log
1549
+ expect(joined).not.toContain("✓ Cloudflare tunnel up");
1550
+ } finally {
1551
+ env.cleanup();
1552
+ }
1553
+ });
1554
+
1555
+ test("prints success when the connector verifies connected", async () => {
1556
+ const env = makeEnv();
1557
+ try {
1558
+ const uuid = "44440000-0000-0000-0000-0000000005ee";
1559
+ seedCreds(env, uuid);
1560
+ const { runner } = queueRunner([
1561
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
1562
+ { code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
1563
+ { code: 0, stdout: "", stderr: "" }, // route dns
1564
+ ]);
1565
+ const { spawner } = fakeSpawner(43003);
1566
+ const logs: string[] = [];
1567
+
1568
+ const code = await exposeCloudflareUp("vault.example.com", {
1569
+ runner,
1570
+ spawner,
1571
+ alive: () => false,
1572
+ kill: () => {},
1573
+ log: (l) => logs.push(l),
1574
+ manifestPath: env.manifestPath,
1575
+ statePath: env.statePath,
1576
+ exposeStatePath: env.exposeStatePath,
1577
+ configPath: env.configPath,
1578
+ logPath: env.logPath,
1579
+ cloudflaredHome: env.cloudflaredHome,
1580
+ configDir: env.configDir,
1581
+ skipHub: true,
1582
+ tunnelName: "parachute",
1583
+ verifyConnection: async () => true,
1584
+ });
1585
+
1586
+ expect(code).toBe(0);
1587
+ const joined = logs.join("\n");
1588
+ expect(joined).toContain("Connector connected.");
1589
+ expect(joined).toContain("✓ Cloudflare tunnel up");
1590
+ } finally {
1591
+ env.cleanup();
1592
+ }
1593
+ });
1594
+
1595
+ test("defaultVerifyConnection polls tunnelConnectionCount and returns true once connected", async () => {
1596
+ // Drives the real default poll loop against a queued runner — first
1597
+ // `tunnel info` reports no conns, the second reports one. No real sleep.
1598
+ let infoCalls = 0;
1599
+ const runner: Runner = async (cmd) => {
1600
+ if (cmd.join(" ").startsWith("cloudflared tunnel info")) {
1601
+ infoCalls += 1;
1602
+ return infoCalls === 1
1603
+ ? { code: 0, stdout: JSON.stringify({ conns: [] }), stderr: "" }
1604
+ : { code: 0, stdout: JSON.stringify({ conns: [{ id: "c1" }] }), stderr: "" };
1605
+ }
1606
+ return { code: 0, stdout: "", stderr: "" };
1607
+ };
1608
+ const connected = await defaultVerifyConnection({
1609
+ runner,
1610
+ tunnelName: "parachute",
1611
+ timeoutMs: 5_000,
1612
+ pollMs: 1,
1613
+ sleep: async () => {},
1614
+ });
1615
+ expect(connected).toBe(true);
1616
+ expect(infoCalls).toBe(2);
1617
+ });
1618
+
1619
+ test("defaultVerifyConnection returns false when the budget elapses with no connection", async () => {
1620
+ const runner: Runner = async () => ({
1621
+ code: 0,
1622
+ stdout: JSON.stringify({ conns: [] }),
1623
+ stderr: "",
1624
+ });
1625
+ const connected = await defaultVerifyConnection({
1626
+ runner,
1627
+ tunnelName: "parachute",
1628
+ timeoutMs: 0, // immediate deadline → one probe then false
1629
+ pollMs: 1,
1630
+ sleep: async () => {},
1631
+ });
1632
+ expect(connected).toBe(false);
1633
+ });
1634
+ });
1382
1635
  });
1383
1636
 
1384
1637
  describe("exposeCloudflareOff", () => {
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ensureHubUnitForExpose, resolveExposeSupervisor } from "../commands/expose-supervisor.ts";
3
+ import type { EnsureHubVersionMatchesResult } from "../hub-unit.ts";
4
+
5
+ /**
6
+ * #590: `ensureHubUnitForExpose` must run the version-check-and-restart at the
7
+ * expose adoption point, so an expose never wires a tunnel to a stale zombie
8
+ * that merely answers /health on the canonical port. These tests drive the
9
+ * version-check seam directly (no real launchctl / live hub).
10
+ */
11
+ describe("ensureHubUnitForExpose — version-check at the expose adoption point (#590)", () => {
12
+ function sup(
13
+ ensureHubUnitOutcome: "already-up" | "started" | "no-unit",
14
+ versionResult: EnsureHubVersionMatchesResult,
15
+ versionSpy?: (port: number) => void,
16
+ ) {
17
+ return resolveExposeSupervisor({
18
+ ensureHubUnit: async ({ port }) => ({
19
+ outcome: ensureHubUnitOutcome,
20
+ port: port ?? 1939,
21
+ messages: ensureHubUnitOutcome === "no-unit" ? ["no hub unit installed"] : [],
22
+ }),
23
+ ensureHubVersion: async ({ port }) => {
24
+ versionSpy?.(port);
25
+ return versionResult;
26
+ },
27
+ });
28
+ }
29
+
30
+ test("hub up + version matches → ok, version check ran with the probed port", async () => {
31
+ const logs: string[] = [];
32
+ let checkedPort: number | undefined;
33
+ const s = sup(
34
+ "already-up",
35
+ {
36
+ outcome: "match",
37
+ runningVersion: "0.6.4-rc.9",
38
+ installedVersion: "0.6.4-rc.9",
39
+ messages: [],
40
+ },
41
+ (p) => {
42
+ checkedPort = p;
43
+ },
44
+ );
45
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
46
+ expect(res.ok).toBe(true);
47
+ expect(checkedPort).toBe(1939);
48
+ });
49
+
50
+ test("hub up but a stale zombie → restarted → ok (tunnel binds to NEW code)", async () => {
51
+ const logs: string[] = [];
52
+ const s = sup("already-up", {
53
+ outcome: "restarted",
54
+ runningVersion: "0.6.4-rc.9",
55
+ installedVersion: "0.6.4-rc.9",
56
+ messages: ["✓ hub unit restarted; now running 0.6.4-rc.9."],
57
+ });
58
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
59
+ expect(res.ok).toBe(true);
60
+ expect(logs.join("\n")).toContain("now running 0.6.4-rc.9");
61
+ });
62
+
63
+ test("hub up but mismatch + NOT unit-managed → expose FAILS (don't tunnel to a zombie)", async () => {
64
+ const logs: string[] = [];
65
+ const s = sup("already-up", {
66
+ outcome: "not-unit-managed",
67
+ runningVersion: "0.5.14-rc.4",
68
+ installedVersion: "0.6.4-rc.9",
69
+ messages: ["⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed."],
70
+ });
71
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
72
+ expect(res.ok).toBe(false);
73
+ expect(logs.join("\n")).toContain("0.5.14-rc.4");
74
+ });
75
+
76
+ test("still-mismatched after restart → expose CONTINUES (warn, don't block)", async () => {
77
+ const logs: string[] = [];
78
+ const s = sup("already-up", {
79
+ outcome: "still-mismatched",
80
+ runningVersion: "0.6.4-rc.8",
81
+ installedVersion: "0.6.4-rc.9",
82
+ messages: ["⚠ restarted the hub unit, but it is still not reporting 0.6.4-rc.9."],
83
+ });
84
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
85
+ expect(res.ok).toBe(true);
86
+ expect(logs.join("\n")).toContain("still not reporting");
87
+ });
88
+
89
+ test("hub NOT up (no unit) → fails BEFORE the version check (no false adoption)", async () => {
90
+ const logs: string[] = [];
91
+ let versionRan = false;
92
+ const s = sup(
93
+ "no-unit",
94
+ { outcome: "match", installedVersion: "0.6.4-rc.9", messages: [] },
95
+ () => {
96
+ versionRan = true;
97
+ },
98
+ );
99
+ const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
100
+ expect(res.ok).toBe(false);
101
+ // The version check only runs once the hub is confirmed up.
102
+ expect(versionRan).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,139 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+ import { classifyDbError, createDbHolder, probeDbLiveness } from "../hub-db-liveness.ts";
4
+
5
+ /** Build a `SQLiteError`-shaped object with the given code + message. */
6
+ function sqliteErr(code: string, message: string): Error & { code: string } {
7
+ const e = new Error(message) as Error & { code: string };
8
+ e.name = "SQLiteError";
9
+ e.code = code;
10
+ return e;
11
+ }
12
+
13
+ describe("classifyDbError (#594)", () => {
14
+ test("the persistent-corruption class is fatal", () => {
15
+ expect(classifyDbError(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("fatal");
16
+ expect(classifyDbError(new Error("disk I/O error"))).toBe("fatal");
17
+ expect(classifyDbError(sqliteErr("SQLITE_CORRUPT", "database disk image is malformed"))).toBe(
18
+ "fatal",
19
+ );
20
+ expect(classifyDbError(sqliteErr("SQLITE_NOTADB", "file is not a database"))).toBe("fatal");
21
+ });
22
+
23
+ test("transient locks are NOT fatal", () => {
24
+ expect(classifyDbError(sqliteErr("SQLITE_BUSY", "database is locked"))).toBe("transient");
25
+ expect(classifyDbError(sqliteErr("SQLITE_LOCKED", "database table is locked"))).toBe(
26
+ "transient",
27
+ );
28
+ });
29
+
30
+ test("unrelated errors classify as other", () => {
31
+ expect(classifyDbError(new Error("UNIQUE constraint failed: users.id"))).toBe("other");
32
+ expect(classifyDbError(new TypeError("undefined is not a function"))).toBe("other");
33
+ expect(classifyDbError(null)).toBe("other");
34
+ });
35
+ });
36
+
37
+ describe("probeDbLiveness (#594)", () => {
38
+ test("returns ok on a live in-memory db", () => {
39
+ const db = new Database(":memory:");
40
+ expect(probeDbLiveness(db)).toBe("ok");
41
+ db.close();
42
+ });
43
+
44
+ test("returns error: <class> on a closed handle, never throws", () => {
45
+ const db = new Database(":memory:");
46
+ db.close();
47
+ const result = probeDbLiveness(db);
48
+ expect(result.startsWith("error:")).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("createDbHolder (#594)", () => {
53
+ test("non-fatal errors are ignored (no reopen, no exit)", () => {
54
+ const initial = new Database(":memory:");
55
+ let reopens = 0;
56
+ let exits = 0;
57
+ const holder = createDbHolder(initial, {
58
+ reopen: () => {
59
+ reopens += 1;
60
+ return new Database(":memory:");
61
+ },
62
+ exit: () => {
63
+ exits += 1;
64
+ },
65
+ log: () => {},
66
+ });
67
+ expect(holder.healOrExit(sqliteErr("SQLITE_BUSY", "database is locked"))).toBe("ignored");
68
+ expect(holder.healOrExit(new Error("UNIQUE constraint failed"))).toBe("ignored");
69
+ expect(reopens).toBe(0);
70
+ expect(exits).toBe(0);
71
+ expect(holder.get()).toBe(initial);
72
+ });
73
+
74
+ test("a fatal error reopens the handle ONCE and swaps it in", () => {
75
+ const initial = new Database(":memory:");
76
+ const fresh = new Database(":memory:");
77
+ let reopens = 0;
78
+ let exits = 0;
79
+ let closedOld = false;
80
+ const holder = createDbHolder(initial, {
81
+ reopen: () => {
82
+ reopens += 1;
83
+ return fresh;
84
+ },
85
+ exit: () => {
86
+ exits += 1;
87
+ },
88
+ closeOld: () => {
89
+ closedOld = true;
90
+ },
91
+ log: () => {},
92
+ });
93
+ expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("healed");
94
+ expect(reopens).toBe(1);
95
+ expect(exits).toBe(0);
96
+ expect(closedOld).toBe(true);
97
+ expect(holder.get()).toBe(fresh);
98
+ fresh.close();
99
+ });
100
+
101
+ test("a fatal error exits(1) when reopen throws", () => {
102
+ const initial = new Database(":memory:");
103
+ let exitCode: number | undefined;
104
+ const holder = createDbHolder(initial, {
105
+ reopen: () => {
106
+ throw sqliteErr("SQLITE_IOERR", "disk I/O error");
107
+ },
108
+ // Non-exiting spy so the test process survives.
109
+ exit: (code) => {
110
+ exitCode = code;
111
+ },
112
+ log: () => {},
113
+ });
114
+ expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("exited");
115
+ expect(exitCode).toBe(1);
116
+ // Handle is unchanged (we couldn't reopen).
117
+ expect(holder.get()).toBe(initial);
118
+ initial.close();
119
+ });
120
+
121
+ test("a fatal error exits(1) when the REOPENED handle is also dead", () => {
122
+ const initial = new Database(":memory:");
123
+ // Reopen returns an already-closed handle → the holder's SELECT 1 verify
124
+ // throws → exit. This is the "state dir still gone after reopen" case.
125
+ const deadFresh = new Database(":memory:");
126
+ deadFresh.close();
127
+ let exitCode: number | undefined;
128
+ const holder = createDbHolder(initial, {
129
+ reopen: () => deadFresh,
130
+ exit: (code) => {
131
+ exitCode = code;
132
+ },
133
+ log: () => {},
134
+ });
135
+ expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("exited");
136
+ expect(exitCode).toBe(1);
137
+ initial.close();
138
+ });
139
+ });