routstrd 0.4.0 → 0.4.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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Pure selection helpers for the wallet cleanup command.
3
+ *
4
+ * These helpers decide *which* stuck operations are safe to clear. The actual
5
+ * state transitions are applied by the in-process coco wallet client so that
6
+ * coco-core's operation services emit their normal events and release proof
7
+ * reservations. Keeping the selection logic here makes it easy to unit test
8
+ * without a wallet database or network access.
9
+ */
10
+
11
+ export interface MintCleanupCandidate {
12
+ id: string;
13
+ state: string;
14
+ /** Quote expiry in epoch seconds. `0` means unknown/not applicable. */
15
+ expiry: number;
16
+ /** Last update time in epoch milliseconds. */
17
+ updatedAt: number;
18
+ /**
19
+ * Last quote state observed from the mint (e.g. "PAID", "ISSUED").
20
+ * Unknown/undefined means no terminal observation has been recorded.
21
+ */
22
+ lastObservedRemoteState?: string;
23
+ }
24
+
25
+ export interface NonMintCleanupCandidate {
26
+ id: string;
27
+ state: string;
28
+ /** Last update time in epoch milliseconds. */
29
+ updatedAt: number;
30
+ }
31
+
32
+ export interface CleanupSelectionOptions<
33
+ TMint extends MintCleanupCandidate,
34
+ TSend extends NonMintCleanupCandidate,
35
+ TMelt extends NonMintCleanupCandidate,
36
+ > {
37
+ mints: TMint[];
38
+ sends: TSend[];
39
+ melts: TMelt[];
40
+ nowMs: number;
41
+ minAgeMs: number;
42
+ }
43
+
44
+ export interface CleanupSelection<
45
+ TMint extends MintCleanupCandidate,
46
+ TSend extends NonMintCleanupCandidate,
47
+ TMelt extends NonMintCleanupCandidate,
48
+ > {
49
+ mintsToFail: TMint[];
50
+ sendsToReclaim: TSend[];
51
+ meltsToCancel: TMelt[];
52
+ }
53
+
54
+ /**
55
+ * Select stuck operations that are old enough to be considered for clearing.
56
+ *
57
+ * - Pending mint quotes are candidates once their bolt11 quote has expired
58
+ * (an expired Lightning invoice can never be paid again) and the mint has
59
+ * not already reported it as PAID/ISSUED. A paid-but-unfinalized quote
60
+ * still has claimable proofs, so it must go through normal recovery instead
61
+ * of being failed locally.
62
+ *
63
+ * Note that expiry alone does not prove a quote was never paid: the payment
64
+ * can have happened before expiry while the daemon was down, leaving no
65
+ * local observation. Callers that fail quotes automatically at startup must
66
+ * therefore confirm UNPAID with the mint first (see
67
+ * settleExpiredMintQuotes in coco-client.ts); only the explicit,
68
+ * user-invoked cleanup command may fail candidates purely locally.
69
+ * - Pending sends are reclaimed (rolled back) only when they are older than
70
+ * `minAgeMs`, so we never roll back a token that a receiver might still
71
+ * legitimately claim.
72
+ * - Prepared melts are cancelled under the same age guard.
73
+ */
74
+ export function selectCleanupOperations<
75
+ TMint extends MintCleanupCandidate,
76
+ TSend extends NonMintCleanupCandidate,
77
+ TMelt extends NonMintCleanupCandidate,
78
+ >(
79
+ options: CleanupSelectionOptions<TMint, TSend, TMelt>,
80
+ ): CleanupSelection<TMint, TSend, TMelt> {
81
+ const { mints, sends, melts, nowMs, minAgeMs } = options;
82
+
83
+ // Expired quotes can never be paid again, but may have been paid before
84
+ // expiry without a local observation: this is a candidate set, and whether
85
+ // failing is safe without a mint round-trip depends on the caller (above).
86
+ const mintsToFail = mints.filter(
87
+ (op) =>
88
+ op.state === "pending" &&
89
+ op.expiry > 0 &&
90
+ op.expiry * 1000 <= nowMs &&
91
+ op.lastObservedRemoteState !== "PAID" &&
92
+ op.lastObservedRemoteState !== "ISSUED",
93
+ );
94
+
95
+ const sendsToReclaim = sends.filter(
96
+ (op) => op.state === "pending" && nowMs - op.updatedAt >= minAgeMs,
97
+ );
98
+
99
+ const meltsToCancel = melts.filter(
100
+ (op) => op.state === "prepared" && nowMs - op.updatedAt >= minAgeMs,
101
+ );
102
+
103
+ return { mintsToFail, sendsToReclaim, meltsToCancel };
104
+ }
@@ -14,7 +14,10 @@ import {
14
14
  assertLegacyCocodNotRunning,
15
15
  claimLegacyCocodPidFile,
16
16
  createCocoClient,
17
+ isZombieProcess,
18
+ settleExpiredMintQuotes,
17
19
  stopLegacyCocod,
20
+ type ExpiredMintQuoteSource,
18
21
  } from "./coco-client";
19
22
 
20
23
  type GuardOptions = NonNullable<
@@ -42,6 +45,19 @@ function socketOnly(path: string): boolean {
42
45
  return path === SOCKET_PATH;
43
46
  }
44
47
 
48
+ async function waitForWalletUnlocked(
49
+ client: Awaited<ReturnType<typeof createCocoClient>>,
50
+ ): Promise<void> {
51
+ const deadline = Date.now() + 30_000;
52
+ while (Date.now() < deadline) {
53
+ const status = await client.getStatus();
54
+ if (status === "UNLOCKED") return;
55
+ if (status === "ERROR") throw new Error("Wallet recovery failed");
56
+ await new Promise((resolve) => setTimeout(resolve, 10));
57
+ }
58
+ throw new Error("Timed out waiting for wallet recovery to complete");
59
+ }
60
+
45
61
  describe("default mint functionality", () => {
46
62
  it("automatically adds default mint when no mints exist", async () => {
47
63
  const walletDir = join(makeTempDir(), "wallet");
@@ -192,7 +208,7 @@ describe("legacy cocod wallet migration", () => {
192
208
  enableNpc: false,
193
209
  });
194
210
  try {
195
- expect(await client.getStatus()).toBe("UNLOCKED");
211
+ await waitForWalletUnlocked(client);
196
212
  expect(await client.getBalances()).toEqual({ [mintUrl]: 10 });
197
213
  } finally {
198
214
  await client.dispose?.();
@@ -214,13 +230,29 @@ describe("legacy cocod wallet migration", () => {
214
230
  });
215
231
  });
216
232
 
233
+ describe("isZombieProcess", () => {
234
+ it("recognizes Linux proc stat zombie state", () => {
235
+ expect(
236
+ isZombieProcess(4242, () => "4242 (routstrd worker) Z 1 4242 4242"),
237
+ ).toBe(true);
238
+ });
239
+
240
+ it("does not mistake a running process or unreadable proc entry for a zombie", () => {
241
+ expect(isZombieProcess(4242, () => "4242 (bun) S 1 4242 4242")).toBe(false);
242
+ expect(
243
+ isZombieProcess(4242, () => {
244
+ throw Object.assign(new Error("missing"), { code: "ENOENT" });
245
+ }),
246
+ ).toBe(false);
247
+ });
248
+ });
249
+
217
250
  describe("assertLegacyCocodNotRunning", () => {
218
- it("does not probe when the legacy socket and PID file do not exist", async () => {
251
+ it("does not probe when the legacy socket does not exist", async () => {
219
252
  const fetchImpl = mock<LegacyFetch>(async () => new Response("pong"));
220
253
 
221
254
  await assertLegacyCocodNotRunning({
222
255
  socketPath: SOCKET_PATH,
223
- pidFilePath: PID_FILE_PATH,
224
256
  pathExists: () => false,
225
257
  fetchImpl,
226
258
  });
@@ -236,7 +268,6 @@ describe("assertLegacyCocodNotRunning", () => {
236
268
  await expect(
237
269
  assertLegacyCocodNotRunning({
238
270
  socketPath: SOCKET_PATH,
239
- pidFilePath: PID_FILE_PATH,
240
271
  pathExists: socketOnly,
241
272
  fetchImpl,
242
273
  }),
@@ -257,7 +288,6 @@ describe("assertLegacyCocodNotRunning", () => {
257
288
  await expect(
258
289
  assertLegacyCocodNotRunning({
259
290
  socketPath: SOCKET_PATH,
260
- pidFilePath: PID_FILE_PATH,
261
291
  pathExists: socketOnly,
262
292
  fetchImpl,
263
293
  }),
@@ -277,14 +307,13 @@ describe("assertLegacyCocodNotRunning", () => {
277
307
  await expect(
278
308
  assertLegacyCocodNotRunning({
279
309
  socketPath: SOCKET_PATH,
280
- pidFilePath: PID_FILE_PATH,
281
310
  pathExists: socketOnly,
282
311
  fetchImpl,
283
312
  }),
284
313
  ).resolves.toBeUndefined();
285
314
  });
286
315
 
287
- it("refuses to continue when the legacy PID is still running", async () => {
316
+ it("allows a live shared PID owner when no cocod socket exists", async () => {
288
317
  const fetchImpl = mock<LegacyFetch>(async () =>
289
318
  Response.json({ output: "pong" }),
290
319
  );
@@ -292,38 +321,26 @@ describe("assertLegacyCocodNotRunning", () => {
292
321
  await expect(
293
322
  assertLegacyCocodNotRunning({
294
323
  socketPath: SOCKET_PATH,
295
- pidFilePath: PID_FILE_PATH,
296
324
  pathExists: (path) => path === PID_FILE_PATH,
297
- readFile: () => "4242\n",
298
- isProcessRunning: (pid) => pid === 4242,
299
325
  fetchImpl,
300
326
  }),
301
- ).rejects.toThrow("Legacy cocod daemon is still running with PID 4242");
327
+ ).resolves.toBeUndefined();
302
328
 
303
329
  expect(fetchImpl).not.toHaveBeenCalled();
304
330
  });
305
331
 
306
- it("ignores a stale PID file when no socket exists", async () => {
307
- await expect(
308
- assertLegacyCocodNotRunning({
309
- socketPath: SOCKET_PATH,
310
- pidFilePath: PID_FILE_PATH,
311
- pathExists: (path) => path === PID_FILE_PATH,
312
- readFile: () => "4242\n",
313
- isProcessRunning: () => false,
314
- }),
315
- ).resolves.toBeUndefined();
316
- });
332
+ it("allows a live shared PID owner when the cocod socket is stale", async () => {
333
+ const fetchImpl = mock<LegacyFetch>(async () => {
334
+ throw Object.assign(new Error("socket unavailable"), {
335
+ code: "ECONNREFUSED",
336
+ });
337
+ });
317
338
 
318
- it("ignores the caller's own migration lock PID", async () => {
319
339
  await expect(
320
340
  assertLegacyCocodNotRunning({
321
341
  socketPath: SOCKET_PATH,
322
- pidFilePath: PID_FILE_PATH,
323
- pathExists: (path) => path === PID_FILE_PATH,
324
- readFile: () => "4242\n",
325
- isProcessRunning: () => true,
326
- ignorePid: 4242,
342
+ pathExists: () => true,
343
+ fetchImpl,
327
344
  }),
328
345
  ).resolves.toBeUndefined();
329
346
  });
@@ -336,7 +353,6 @@ describe("assertLegacyCocodNotRunning", () => {
336
353
  await expect(
337
354
  assertLegacyCocodNotRunning({
338
355
  socketPath: SOCKET_PATH,
339
- pidFilePath: PID_FILE_PATH,
340
356
  pathExists: socketOnly,
341
357
  fetchImpl,
342
358
  }),
@@ -453,7 +469,7 @@ describe("claimLegacyCocodPidFile", () => {
453
469
  readFile: () => "4242",
454
470
  isProcessRunning: () => true,
455
471
  }),
456
- ).toThrow("Another cocod or routstrd process may be starting");
472
+ ).toThrow("PID 4242 is still running and holds it");
457
473
  });
458
474
 
459
475
  it("replaces a confirmed stale PID file before claiming it", () => {
@@ -505,4 +521,149 @@ describe("claimLegacyCocodPidFile", () => {
505
521
  release();
506
522
  expect(removed).toBe(false);
507
523
  });
524
+
525
+ it("registers and removes synchronous process-exit cleanup", () => {
526
+ const before = process.listenerCount("exit");
527
+ const release = claimLegacyCocodPidFile({
528
+ pidFilePath: PID_FILE_PATH,
529
+ pid: 4242,
530
+ openExclusive: () => 7,
531
+ writePid: () => {},
532
+ closeFile: () => {},
533
+ readFile: () => "4242",
534
+ removeFile: () => {},
535
+ });
536
+
537
+ expect(process.listenerCount("exit")).toBe(before + 1);
538
+ release();
539
+ expect(process.listenerCount("exit")).toBe(before);
540
+ });
541
+ });
542
+
543
+ describe("settleExpiredMintQuotes", () => {
544
+ const EXPIRED_S = 1_000_000; // epoch seconds, long past
545
+ const NOW_MS = 2_000_000_000_000;
546
+
547
+ function pendingMintOp(overrides: Record<string, unknown> = {}) {
548
+ return {
549
+ id: "op-1",
550
+ mintUrl: "https://mint.example.com",
551
+ quoteId: "quote-1",
552
+ state: "pending",
553
+ expiry: EXPIRED_S,
554
+ updatedAt: NOW_MS - 60_000,
555
+ lastObservedRemoteState: undefined,
556
+ ...overrides,
557
+ };
558
+ }
559
+
560
+ function fakeSource(
561
+ ops: Array<Record<string, unknown>>,
562
+ behavior: {
563
+ observe?: (id: string) => Promise<{ category: "waiting" | "ready" | "completed" | "terminal" }>;
564
+ } = {},
565
+ ) {
566
+ const failPendingOperation = mock(
567
+ async (
568
+ _op: { id: string },
569
+ _failure: { reason: string; retryable?: boolean; observedAt: number },
570
+ ) => ({}),
571
+ );
572
+ const observePendingOperation = mock(
573
+ behavior.observe ??
574
+ (async (
575
+ _id: string,
576
+ ): Promise<{ category: "waiting" | "ready" | "completed" | "terminal" }> => ({
577
+ category: "waiting",
578
+ })),
579
+ );
580
+ const source = {
581
+ ops: { mint: { listPending: async () => ops } },
582
+ mintOperationService: { observePendingOperation, failPendingOperation },
583
+ } as unknown as ExpiredMintQuoteSource;
584
+ return { source, observePendingOperation, failPendingOperation };
585
+ }
586
+
587
+ it("fails an expired quote locally when its mint confirms it is unpaid", async () => {
588
+ const op = pendingMintOp();
589
+ const { source, failPendingOperation } = fakeSource([op]);
590
+
591
+ const result = await settleExpiredMintQuotes(source, NOW_MS);
592
+
593
+ expect(result).toEqual({ failed: 1, leftForRecovery: 0, unobserved: 0 });
594
+ expect(failPendingOperation).toHaveBeenCalledTimes(1);
595
+ expect(failPendingOperation.mock.calls[0]?.[0]).toEqual({ id: "op-1" });
596
+ });
597
+
598
+ it("leaves an expired quote observed as PAID for mint recovery", async () => {
599
+ const op = pendingMintOp();
600
+ const { source, failPendingOperation } = fakeSource([op], {
601
+ observe: async () => ({ category: "ready" }),
602
+ });
603
+
604
+ const result = await settleExpiredMintQuotes(source, NOW_MS);
605
+
606
+ expect(result).toEqual({ failed: 0, leftForRecovery: 1, unobserved: 0 });
607
+ expect(failPendingOperation).not.toHaveBeenCalled();
608
+ });
609
+
610
+ it("leaves an expired quote observed as ISSUED for mint recovery", async () => {
611
+ const op = pendingMintOp();
612
+ const { source, failPendingOperation } = fakeSource([op], {
613
+ observe: async () => ({ category: "completed" }),
614
+ });
615
+
616
+ const result = await settleExpiredMintQuotes(source, NOW_MS);
617
+
618
+ expect(result).toEqual({ failed: 0, leftForRecovery: 1, unobserved: 0 });
619
+ expect(failPendingOperation).not.toHaveBeenCalled();
620
+ });
621
+
622
+ it("leaves quotes pending when their mint cannot be checked", async () => {
623
+ const op = pendingMintOp();
624
+ const { source, failPendingOperation } = fakeSource([op], {
625
+ observe: async () => {
626
+ throw new Error("Network request failed");
627
+ },
628
+ });
629
+
630
+ const result = await settleExpiredMintQuotes(source, NOW_MS);
631
+
632
+ expect(result).toEqual({ failed: 0, leftForRecovery: 0, unobserved: 1 });
633
+ expect(failPendingOperation).not.toHaveBeenCalled();
634
+ });
635
+
636
+ it("stops observing once the shared deadline is exhausted", async () => {
637
+ const ops = [
638
+ pendingMintOp({ id: "op-1", quoteId: "q-1" }),
639
+ pendingMintOp({ id: "op-2", quoteId: "q-2" }),
640
+ ];
641
+ const { source, failPendingOperation } = fakeSource(ops, {
642
+ // A hung mint: the observation never settles.
643
+ observe: () => new Promise(() => {}),
644
+ });
645
+
646
+ const started = Date.now();
647
+ const result = await settleExpiredMintQuotes(source, NOW_MS, 50);
648
+
649
+ expect(Date.now() - started).toBeLessThan(5_000);
650
+ expect(result).toEqual({ failed: 0, leftForRecovery: 0, unobserved: 2 });
651
+ expect(failPendingOperation).not.toHaveBeenCalled();
652
+ });
653
+
654
+ it("does not touch unexpired or already-observed quotes", async () => {
655
+ const ops = [
656
+ pendingMintOp({ id: "unexpired", expiry: NOW_MS / 1000 + 600 }),
657
+ pendingMintOp({ id: "seen-paid", lastObservedRemoteState: "PAID" }),
658
+ pendingMintOp({ id: "seen-issued", lastObservedRemoteState: "ISSUED" }),
659
+ ];
660
+ const { source, observePendingOperation, failPendingOperation } =
661
+ fakeSource(ops);
662
+
663
+ const result = await settleExpiredMintQuotes(source, NOW_MS);
664
+
665
+ expect(result).toEqual({ failed: 0, leftForRecovery: 0, unobserved: 0 });
666
+ expect(observePendingOperation).not.toHaveBeenCalled();
667
+ expect(failPendingOperation).not.toHaveBeenCalled();
668
+ });
508
669
  });