routstrd 0.4.7 → 0.4.9
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 +96 -6
- package/SKILL.md +341 -0
- package/dist/daemon/index.js +31092 -31824
- package/dist/index.js +162637 -14155
- package/package.json +9 -4
- package/src/cli.ts +279 -110
- package/src/daemon/args.ts +4 -5
- package/src/daemon/http/index.ts +37 -2
- package/src/daemon/index.ts +94 -33
- package/src/daemon/wallet/auto-refill.ts +1 -1
- package/src/daemon/wallet/coco-client.ts +205 -3
- package/src/daemon/wallet/cocod-client.ts +19 -3
- package/src/daemon/wallet/index.ts +1 -1
- package/src/index.ts +9 -1
- package/src/runtime.ts +55 -0
- package/src/start-daemon.ts +4 -8
- package/src/utils/clients.ts +74 -1
- package/src/utils/config.ts +14 -0
- package/src/utils/daemon-stop.ts +125 -0
- package/src/utils/standalone-update.ts +214 -0
- package/src/utils/update-checker.ts +81 -18
- package/src/version.ts +3 -0
- package/src/cli.test.ts +0 -192
- package/src/daemon/fatal-error.test.ts +0 -104
- package/src/daemon/http/usage-summary.test.ts +0 -272
- package/src/daemon/models.test.ts +0 -81
- package/src/daemon/wallet/cleanup.test.ts +0 -168
- package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
- package/src/daemon/wallet/coco-client.test.ts +0 -669
- package/src/daemon/wallet/diagnostics.test.ts +0 -378
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +0 -171
- package/src/daemon/wallet/receive-dedup.test.ts +0 -238
|
@@ -1,669 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
-
import { gunzipSync } from "bun";
|
|
3
|
-
import {
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
mkdirSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
rmSync,
|
|
9
|
-
writeFileSync,
|
|
10
|
-
} from "fs";
|
|
11
|
-
import { tmpdir } from "os";
|
|
12
|
-
import { join } from "path";
|
|
13
|
-
import {
|
|
14
|
-
assertLegacyCocodNotRunning,
|
|
15
|
-
claimLegacyCocodPidFile,
|
|
16
|
-
createCocoClient,
|
|
17
|
-
isZombieProcess,
|
|
18
|
-
settleExpiredMintQuotes,
|
|
19
|
-
stopLegacyCocod,
|
|
20
|
-
type ExpiredMintQuoteSource,
|
|
21
|
-
} from "./coco-client";
|
|
22
|
-
|
|
23
|
-
type GuardOptions = NonNullable<
|
|
24
|
-
Parameters<typeof assertLegacyCocodNotRunning>[0]
|
|
25
|
-
>;
|
|
26
|
-
type LegacyFetch = NonNullable<GuardOptions["fetchImpl"]>;
|
|
27
|
-
|
|
28
|
-
const SOCKET_PATH = "/tmp/routstrd-test/cocod.sock";
|
|
29
|
-
const PID_FILE_PATH = "/tmp/routstrd-test/cocod.pid";
|
|
30
|
-
const tempDirs: string[] = [];
|
|
31
|
-
|
|
32
|
-
function makeTempDir(): string {
|
|
33
|
-
const dir = mkdtempSync(join(tmpdir(), "routstrd-coco-migration-test-"));
|
|
34
|
-
tempDirs.push(dir);
|
|
35
|
-
return dir;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
afterEach(() => {
|
|
39
|
-
for (const dir of tempDirs.splice(0)) {
|
|
40
|
-
rmSync(dir, { recursive: true, force: true });
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
function socketOnly(path: string): boolean {
|
|
45
|
-
return path === SOCKET_PATH;
|
|
46
|
-
}
|
|
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
|
-
|
|
61
|
-
describe("default mint functionality", () => {
|
|
62
|
-
it("automatically adds default mint when no mints exist", async () => {
|
|
63
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
64
|
-
mkdirSync(walletDir, { recursive: true });
|
|
65
|
-
writeFileSync(
|
|
66
|
-
join(walletDir, "config.json"),
|
|
67
|
-
JSON.stringify({
|
|
68
|
-
version: 1,
|
|
69
|
-
mnemonic:
|
|
70
|
-
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
71
|
-
encrypted: false,
|
|
72
|
-
}),
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
const client = await createCocoClient({
|
|
76
|
-
walletDir,
|
|
77
|
-
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
78
|
-
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
79
|
-
});
|
|
80
|
-
try {
|
|
81
|
-
expect(readFileSync(join(walletDir, "wallet.pid"), "utf8")).toBe(
|
|
82
|
-
String(process.pid),
|
|
83
|
-
);
|
|
84
|
-
expect(readFileSync(join(walletDir, "legacy-cocod.pid"), "utf8")).toBe(
|
|
85
|
-
String(process.pid),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
const mints = await client.listMints();
|
|
89
|
-
expect(mints).toContain("https://mint.cubabitcoin.org");
|
|
90
|
-
|
|
91
|
-
const defaultMint = await client.getDefaultMint();
|
|
92
|
-
expect(defaultMint).toBe("https://mint.cubabitcoin.org");
|
|
93
|
-
} finally {
|
|
94
|
-
await client.dispose?.();
|
|
95
|
-
}
|
|
96
|
-
expect(existsSync(join(walletDir, "wallet.pid"))).toBe(false);
|
|
97
|
-
expect(existsSync(join(walletDir, "legacy-cocod.pid"))).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("respects existing default mint in config", async () => {
|
|
101
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
102
|
-
const configuredDefault = "https://mint.cubabitcoin.org";
|
|
103
|
-
mkdirSync(walletDir, { recursive: true });
|
|
104
|
-
|
|
105
|
-
// Create config with an explicit defaultMint
|
|
106
|
-
writeFileSync(
|
|
107
|
-
join(walletDir, "config.json"),
|
|
108
|
-
JSON.stringify({
|
|
109
|
-
version: 1,
|
|
110
|
-
mnemonic:
|
|
111
|
-
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
112
|
-
encrypted: false,
|
|
113
|
-
defaultMintUrl: configuredDefault,
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
|
|
117
|
-
const client = await createCocoClient({
|
|
118
|
-
walletDir,
|
|
119
|
-
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
120
|
-
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
121
|
-
});
|
|
122
|
-
try {
|
|
123
|
-
// Should respect the configured default mint
|
|
124
|
-
const defaultMint = await client.getDefaultMint();
|
|
125
|
-
expect(defaultMint).toBe(configuredDefault);
|
|
126
|
-
|
|
127
|
-
// The mint should have been auto-added as trusted
|
|
128
|
-
const mints = await client.listMints();
|
|
129
|
-
expect(mints).toContain(configuredDefault);
|
|
130
|
-
} finally {
|
|
131
|
-
await client.dispose?.();
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("allows setting default mint to an already trusted mint", async () => {
|
|
136
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
137
|
-
mkdirSync(walletDir, { recursive: true });
|
|
138
|
-
writeFileSync(
|
|
139
|
-
join(walletDir, "config.json"),
|
|
140
|
-
JSON.stringify({
|
|
141
|
-
version: 1,
|
|
142
|
-
mnemonic:
|
|
143
|
-
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
144
|
-
encrypted: false,
|
|
145
|
-
}),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
const client = await createCocoClient({
|
|
149
|
-
walletDir,
|
|
150
|
-
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
151
|
-
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
152
|
-
});
|
|
153
|
-
try {
|
|
154
|
-
// The initial default should be the auto-added Cuba mint
|
|
155
|
-
const initialDefault = await client.getDefaultMint();
|
|
156
|
-
expect(initialDefault).toBe("https://mint.cubabitcoin.org");
|
|
157
|
-
|
|
158
|
-
// Setting the same mint should work
|
|
159
|
-
const message = await client.setDefaultMint(
|
|
160
|
-
"https://mint.cubabitcoin.org",
|
|
161
|
-
);
|
|
162
|
-
expect(message).toContain("https://mint.cubabitcoin.org");
|
|
163
|
-
|
|
164
|
-
const defaultMint = await client.getDefaultMint();
|
|
165
|
-
expect(defaultMint).toBe("https://mint.cubabitcoin.org");
|
|
166
|
-
} finally {
|
|
167
|
-
await client.dispose?.();
|
|
168
|
-
}
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe("legacy cocod wallet migration", () => {
|
|
173
|
-
it("opens an existing unencrypted config and preserves database balances", async () => {
|
|
174
|
-
const walletDir = join(makeTempDir(), "wallet");
|
|
175
|
-
const mintUrl = "https://mint.example.com";
|
|
176
|
-
mkdirSync(walletDir, { recursive: true });
|
|
177
|
-
|
|
178
|
-
// Note: Setting defaultMint to Cuba mint because the fixture database
|
|
179
|
-
// apparently doesn't preserve trusted mints correctly across coco-core versions,
|
|
180
|
-
// and we can't contact the example.com mint. The important part of this test
|
|
181
|
-
// is that balances are preserved, not the specific mint URL.
|
|
182
|
-
writeFileSync(
|
|
183
|
-
join(walletDir, "config.json"),
|
|
184
|
-
JSON.stringify({
|
|
185
|
-
version: 1,
|
|
186
|
-
mnemonic:
|
|
187
|
-
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
188
|
-
encrypted: false,
|
|
189
|
-
defaultMintUrl: "https://mint.cubabitcoin.org",
|
|
190
|
-
}),
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
// This fixture was generated with @routstr/cocod 0.0.24 using its
|
|
194
|
-
// coco-cashu-sqlite-bun 1.1.2-rc.50 adapter. Keeping it frozen prevents
|
|
195
|
-
// this test from accidentally creating its "legacy" database with the
|
|
196
|
-
// same current adapter that createCocoClient uses to read it.
|
|
197
|
-
const fixture = readFileSync(
|
|
198
|
-
join(import.meta.dir, "fixtures", "cocod-0.0.24-wallet.db.gz"),
|
|
199
|
-
);
|
|
200
|
-
writeFileSync(join(walletDir, "coco.db"), gunzipSync(fixture));
|
|
201
|
-
|
|
202
|
-
// NPC is disabled here: this test verifies database migration, and the
|
|
203
|
-
// real plugin would otherwise open a websocket to npubx.cash.
|
|
204
|
-
const client = await createCocoClient({
|
|
205
|
-
walletDir,
|
|
206
|
-
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
207
|
-
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
208
|
-
enableNpc: false,
|
|
209
|
-
});
|
|
210
|
-
try {
|
|
211
|
-
await waitForWalletUnlocked(client);
|
|
212
|
-
expect(await client.getBalances()).toEqual({ [mintUrl]: 10 });
|
|
213
|
-
} finally {
|
|
214
|
-
await client.dispose?.();
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Prove the migrated schema remains reopenable and the pre-existing
|
|
218
|
-
// proofs survive a complete in-process wallet restart.
|
|
219
|
-
const reopenedClient = await createCocoClient({
|
|
220
|
-
walletDir,
|
|
221
|
-
legacySocketPath: join(walletDir, "legacy-cocod.sock"),
|
|
222
|
-
legacyPidPath: join(walletDir, "legacy-cocod.pid"),
|
|
223
|
-
enableNpc: false,
|
|
224
|
-
});
|
|
225
|
-
try {
|
|
226
|
-
expect(await reopenedClient.getBalances()).toEqual({ [mintUrl]: 10 });
|
|
227
|
-
} finally {
|
|
228
|
-
await reopenedClient.dispose?.();
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
});
|
|
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
|
-
|
|
250
|
-
describe("assertLegacyCocodNotRunning", () => {
|
|
251
|
-
it("does not probe when the legacy socket does not exist", async () => {
|
|
252
|
-
const fetchImpl = mock<LegacyFetch>(async () => new Response("pong"));
|
|
253
|
-
|
|
254
|
-
await assertLegacyCocodNotRunning({
|
|
255
|
-
socketPath: SOCKET_PATH,
|
|
256
|
-
pathExists: () => false,
|
|
257
|
-
fetchImpl,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
expect(fetchImpl).not.toHaveBeenCalled();
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("refuses to continue when cocod responds on the legacy socket", async () => {
|
|
264
|
-
const fetchImpl = mock<LegacyFetch>(async () =>
|
|
265
|
-
Response.json({ output: "pong" }),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
await expect(
|
|
269
|
-
assertLegacyCocodNotRunning({
|
|
270
|
-
socketPath: SOCKET_PATH,
|
|
271
|
-
pathExists: socketOnly,
|
|
272
|
-
fetchImpl,
|
|
273
|
-
}),
|
|
274
|
-
).rejects.toThrow("Legacy cocod daemon is still running");
|
|
275
|
-
|
|
276
|
-
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
277
|
-
expect(fetchImpl.mock.calls[0]?.[0]).toBe("http://localhost/ping");
|
|
278
|
-
expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ unix: SOCKET_PATH });
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it.each(["ENOENT", "ECONNREFUSED", "FailedToOpenSocket"])(
|
|
282
|
-
"allows startup for a stale socket that fails with %s",
|
|
283
|
-
async (code) => {
|
|
284
|
-
const fetchImpl = mock<LegacyFetch>(async () => {
|
|
285
|
-
throw Object.assign(new Error("socket unavailable"), { code });
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
await expect(
|
|
289
|
-
assertLegacyCocodNotRunning({
|
|
290
|
-
socketPath: SOCKET_PATH,
|
|
291
|
-
pathExists: socketOnly,
|
|
292
|
-
fetchImpl,
|
|
293
|
-
}),
|
|
294
|
-
).resolves.toBeUndefined();
|
|
295
|
-
},
|
|
296
|
-
);
|
|
297
|
-
|
|
298
|
-
it("recognizes stale socket errors nested under cause", async () => {
|
|
299
|
-
const fetchImpl = mock<LegacyFetch>(async () => {
|
|
300
|
-
throw new TypeError("fetch failed", {
|
|
301
|
-
cause: Object.assign(new Error("connection refused"), {
|
|
302
|
-
code: "ECONNREFUSED",
|
|
303
|
-
}),
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
await expect(
|
|
308
|
-
assertLegacyCocodNotRunning({
|
|
309
|
-
socketPath: SOCKET_PATH,
|
|
310
|
-
pathExists: socketOnly,
|
|
311
|
-
fetchImpl,
|
|
312
|
-
}),
|
|
313
|
-
).resolves.toBeUndefined();
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it("allows a live shared PID owner when no cocod socket exists", async () => {
|
|
317
|
-
const fetchImpl = mock<LegacyFetch>(async () =>
|
|
318
|
-
Response.json({ output: "pong" }),
|
|
319
|
-
);
|
|
320
|
-
|
|
321
|
-
await expect(
|
|
322
|
-
assertLegacyCocodNotRunning({
|
|
323
|
-
socketPath: SOCKET_PATH,
|
|
324
|
-
pathExists: (path) => path === PID_FILE_PATH,
|
|
325
|
-
fetchImpl,
|
|
326
|
-
}),
|
|
327
|
-
).resolves.toBeUndefined();
|
|
328
|
-
|
|
329
|
-
expect(fetchImpl).not.toHaveBeenCalled();
|
|
330
|
-
});
|
|
331
|
-
|
|
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
|
-
});
|
|
338
|
-
|
|
339
|
-
await expect(
|
|
340
|
-
assertLegacyCocodNotRunning({
|
|
341
|
-
socketPath: SOCKET_PATH,
|
|
342
|
-
pathExists: () => true,
|
|
343
|
-
fetchImpl,
|
|
344
|
-
}),
|
|
345
|
-
).resolves.toBeUndefined();
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
it("fails closed when the socket cannot be probed safely", async () => {
|
|
349
|
-
const fetchImpl = mock<LegacyFetch>(async () => {
|
|
350
|
-
throw Object.assign(new Error("permission denied"), { code: "EACCES" });
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
await expect(
|
|
354
|
-
assertLegacyCocodNotRunning({
|
|
355
|
-
socketPath: SOCKET_PATH,
|
|
356
|
-
pathExists: socketOnly,
|
|
357
|
-
fetchImpl,
|
|
358
|
-
}),
|
|
359
|
-
).rejects.toThrow("Cannot verify whether the legacy cocod daemon has stopped");
|
|
360
|
-
});
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
describe("stopLegacyCocod", () => {
|
|
364
|
-
it("stops a live PID only when legacy cocod responds on its socket", async () => {
|
|
365
|
-
let running = true;
|
|
366
|
-
const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {
|
|
367
|
-
running = false;
|
|
368
|
-
});
|
|
369
|
-
const fetchImpl = mock<LegacyFetch>(async () =>
|
|
370
|
-
Response.json({ output: "pong" }),
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
await stopLegacyCocod({
|
|
374
|
-
socketPath: SOCKET_PATH,
|
|
375
|
-
pidFilePath: PID_FILE_PATH,
|
|
376
|
-
pathExists: () => true,
|
|
377
|
-
readFile: () => "4242\n",
|
|
378
|
-
isProcessRunning: () => running,
|
|
379
|
-
fetchImpl,
|
|
380
|
-
killProcess,
|
|
381
|
-
pollIntervalMs: 1,
|
|
382
|
-
timeoutMs: 50,
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
386
|
-
expect(killProcess).toHaveBeenCalledWith(4242, "SIGTERM");
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
it("does not kill a routstrd PID that owns the shared pidfile with a stale socket", async () => {
|
|
390
|
-
const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {});
|
|
391
|
-
const fetchImpl = mock<LegacyFetch>(async () => {
|
|
392
|
-
throw Object.assign(new Error("socket unavailable"), {
|
|
393
|
-
code: "ECONNREFUSED",
|
|
394
|
-
});
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
await stopLegacyCocod({
|
|
398
|
-
socketPath: SOCKET_PATH,
|
|
399
|
-
pidFilePath: PID_FILE_PATH,
|
|
400
|
-
pathExists: () => true,
|
|
401
|
-
readFile: () => "4242\n",
|
|
402
|
-
isProcessRunning: () => true,
|
|
403
|
-
fetchImpl,
|
|
404
|
-
killProcess,
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
408
|
-
expect(killProcess).not.toHaveBeenCalled();
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
it("does not kill a live pidfile owner when no legacy socket exists", async () => {
|
|
412
|
-
const killProcess = mock((_pid: number, _signal: NodeJS.Signals) => {});
|
|
413
|
-
const fetchImpl = mock<LegacyFetch>(async () =>
|
|
414
|
-
Response.json({ output: "pong" }),
|
|
415
|
-
);
|
|
416
|
-
|
|
417
|
-
await stopLegacyCocod({
|
|
418
|
-
socketPath: SOCKET_PATH,
|
|
419
|
-
pidFilePath: PID_FILE_PATH,
|
|
420
|
-
pathExists: (path) => path === PID_FILE_PATH,
|
|
421
|
-
readFile: () => "4242\n",
|
|
422
|
-
isProcessRunning: () => true,
|
|
423
|
-
fetchImpl,
|
|
424
|
-
killProcess,
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
expect(fetchImpl).not.toHaveBeenCalled();
|
|
428
|
-
expect(killProcess).not.toHaveBeenCalled();
|
|
429
|
-
});
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
describe("claimLegacyCocodPidFile", () => {
|
|
433
|
-
it("claims the PID file exclusively and releases its own claim", () => {
|
|
434
|
-
let storedPid: string | undefined;
|
|
435
|
-
let removed = false;
|
|
436
|
-
const opened: Array<{ path: string; fd: number }> = [];
|
|
437
|
-
|
|
438
|
-
const release = claimLegacyCocodPidFile({
|
|
439
|
-
pidFilePath: PID_FILE_PATH,
|
|
440
|
-
pid: 4242,
|
|
441
|
-
openExclusive: (path) => {
|
|
442
|
-
opened.push({ path, fd: 7 });
|
|
443
|
-
return 7;
|
|
444
|
-
},
|
|
445
|
-
writePid: (fd, pid) => {
|
|
446
|
-
expect(fd).toBe(7);
|
|
447
|
-
storedPid = String(pid);
|
|
448
|
-
},
|
|
449
|
-
closeFile: (fd) => expect(fd).toBe(7),
|
|
450
|
-
readFile: () => storedPid || "",
|
|
451
|
-
removeFile: () => {
|
|
452
|
-
removed = true;
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
expect(opened).toEqual([{ path: PID_FILE_PATH, fd: 7 }]);
|
|
457
|
-
expect(storedPid).toBe("4242");
|
|
458
|
-
release();
|
|
459
|
-
expect(removed).toBe(true);
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
it("refuses startup when another process wins the atomic claim", () => {
|
|
463
|
-
expect(() =>
|
|
464
|
-
claimLegacyCocodPidFile({
|
|
465
|
-
pidFilePath: PID_FILE_PATH,
|
|
466
|
-
openExclusive: () => {
|
|
467
|
-
throw Object.assign(new Error("exists"), { code: "EEXIST" });
|
|
468
|
-
},
|
|
469
|
-
readFile: () => "4242",
|
|
470
|
-
isProcessRunning: () => true,
|
|
471
|
-
}),
|
|
472
|
-
).toThrow("PID 4242 is still running and holds it");
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it("replaces a confirmed stale PID file before claiming it", () => {
|
|
476
|
-
let openAttempts = 0;
|
|
477
|
-
let removed = false;
|
|
478
|
-
let storedPid = "4242";
|
|
479
|
-
|
|
480
|
-
const release = claimLegacyCocodPidFile({
|
|
481
|
-
pidFilePath: PID_FILE_PATH,
|
|
482
|
-
pid: 9001,
|
|
483
|
-
openExclusive: () => {
|
|
484
|
-
openAttempts++;
|
|
485
|
-
if (openAttempts === 1) {
|
|
486
|
-
throw Object.assign(new Error("exists"), { code: "EEXIST" });
|
|
487
|
-
}
|
|
488
|
-
return 7;
|
|
489
|
-
},
|
|
490
|
-
readFile: () => storedPid,
|
|
491
|
-
isProcessRunning: () => false,
|
|
492
|
-
removeFile: () => {
|
|
493
|
-
removed = true;
|
|
494
|
-
},
|
|
495
|
-
writePid: (_fd, pid) => {
|
|
496
|
-
storedPid = String(pid);
|
|
497
|
-
},
|
|
498
|
-
closeFile: () => {},
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
expect(openAttempts).toBe(2);
|
|
502
|
-
expect(removed).toBe(true);
|
|
503
|
-
expect(storedPid).toBe("9001");
|
|
504
|
-
release();
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it("does not remove a PID file that no longer belongs to this process", () => {
|
|
508
|
-
let removed = false;
|
|
509
|
-
const release = claimLegacyCocodPidFile({
|
|
510
|
-
pidFilePath: PID_FILE_PATH,
|
|
511
|
-
pid: 4242,
|
|
512
|
-
openExclusive: () => 7,
|
|
513
|
-
writePid: () => {},
|
|
514
|
-
closeFile: () => {},
|
|
515
|
-
readFile: () => "9001",
|
|
516
|
-
removeFile: () => {
|
|
517
|
-
removed = true;
|
|
518
|
-
},
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
release();
|
|
522
|
-
expect(removed).toBe(false);
|
|
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
|
-
});
|
|
669
|
-
});
|