routstrd 0.4.0 → 0.4.2
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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/README.md +5 -0
- package/bun.lock +316 -2
- package/dist/daemon/index.js +70789 -54681
- package/dist/index.js +729 -183
- package/package.json +2 -2
- package/src/cli.ts +258 -38
- package/src/daemon/config-store.ts +79 -4
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.test.ts +81 -0
- package/src/daemon/models.ts +18 -2
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/pi.ts +17 -1
- package/src/start-daemon.ts +21 -1
- package/src/tui/usage/data.ts +13 -2
- package/src/tui/usage/render.ts +8 -0
- package/src/utils/config.ts +3 -1
- package/tests/daemon/config-store.perms.test.ts +55 -0
- package/tests/daemon/config-store.scenario.ts +113 -0
- package/tests/integrations/hermes.test.ts +42 -5
|
@@ -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
|
-
|
|
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
|
|
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("
|
|
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
|
-
).
|
|
327
|
+
).resolves.toBeUndefined();
|
|
302
328
|
|
|
303
329
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
304
330
|
});
|
|
305
331
|
|
|
306
|
-
it("
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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("
|
|
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
|
});
|