routstrd 0.2.8 → 0.2.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 +17 -20
- package/SKILL.md +46 -13
- package/dist/daemon/index.js +137 -28
- package/dist/index.js +293 -177
- package/package.json +1 -1
- package/src/daemon/wallet/cocod-client.ts +22 -6
- package/src/start-daemon.ts +52 -30
- package/src/utils/daemon-client.ts +6 -26
- package/src/utils/process-lock.ts +136 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
3
|
import { logger } from "../../utils/logger";
|
|
4
|
+
import { withCrossProcessLock } from "../../utils/process-lock";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CONFIG_DIR =
|
|
6
7
|
process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
|
|
@@ -139,6 +140,7 @@ export function createCocodClient(
|
|
|
139
140
|
): CocodClient {
|
|
140
141
|
const executable = resolveCocodExecutable(options.cocodPath);
|
|
141
142
|
const socketPath = options.socketPath || DEFAULT_SOCKET_PATH;
|
|
143
|
+
const startupLockPath = `${socketPath}.startup.lock`;
|
|
142
144
|
const fetchImpl = options.fetchImpl || (fetch as CocodFetch);
|
|
143
145
|
const pollIntervalMs = options.pollIntervalMs ?? 100;
|
|
144
146
|
const startupTimeoutMs = options.startupTimeoutMs ?? 5000;
|
|
@@ -228,7 +230,7 @@ export function createCocodClient(
|
|
|
228
230
|
|
|
229
231
|
async function startDaemon(): Promise<void> {
|
|
230
232
|
const env = { ...process.env, COCOD_SOCKET: socketPath };
|
|
231
|
-
const proc = spawnDaemon([executable, "
|
|
233
|
+
const proc = spawnDaemon([executable, "init"], env);
|
|
232
234
|
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
|
|
233
235
|
let exitCode: number | null = null;
|
|
234
236
|
|
|
@@ -239,8 +241,8 @@ export function createCocodClient(
|
|
|
239
241
|
for (let i = 0; i < maxPolls; i++) {
|
|
240
242
|
await delay(pollIntervalMs);
|
|
241
243
|
|
|
242
|
-
if (exitCode !== null) {
|
|
243
|
-
throw new Error(`cocod
|
|
244
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
245
|
+
throw new Error(`cocod init exited early with code ${exitCode}`);
|
|
244
246
|
}
|
|
245
247
|
|
|
246
248
|
if (await pingInternal()) {
|
|
@@ -250,7 +252,7 @@ export function createCocodClient(
|
|
|
250
252
|
}
|
|
251
253
|
|
|
252
254
|
throw new Error(
|
|
253
|
-
`cocod
|
|
255
|
+
`cocod failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
|
|
254
256
|
);
|
|
255
257
|
}
|
|
256
258
|
|
|
@@ -260,8 +262,22 @@ export function createCocodClient(
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
if (!startPromise) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
+
startPromise = withCrossProcessLock(
|
|
266
|
+
startupLockPath,
|
|
267
|
+
async () => {
|
|
268
|
+
if (await pingInternal()) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
logger.debug(`Starting cocod daemon via ${executable} init...`);
|
|
273
|
+
await startDaemon();
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
acquireTimeoutMs: startupTimeoutMs + 30_000,
|
|
277
|
+
staleAfterMs: startupTimeoutMs + 30_000,
|
|
278
|
+
log: (message) => logger.debug(message),
|
|
279
|
+
},
|
|
280
|
+
).finally(() => {
|
|
265
281
|
startPromise = null;
|
|
266
282
|
});
|
|
267
283
|
}
|
package/src/start-daemon.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { logger } from "./utils/logger";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
-
import { LOGS_DIR } from "./utils/config";
|
|
3
|
+
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
|
4
|
+
import { withCrossProcessLock } from "./utils/process-lock";
|
|
5
|
+
|
|
6
|
+
const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
|
4
7
|
|
|
5
8
|
function getTodayLogFile(): string {
|
|
6
9
|
const now = new Date();
|
|
@@ -10,27 +13,32 @@ function getTodayLogFile(): string {
|
|
|
10
13
|
return `${LOGS_DIR}/${year}-${month}-${day}.log`;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
async function isDaemonHealthy(port: string): Promise<boolean> {
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
19
|
+
try {
|
|
20
|
+
const existing = await fetch(`http://localhost:${port}/health`, {
|
|
21
|
+
signal: controller.signal,
|
|
22
|
+
});
|
|
23
|
+
return existing.ok;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timeoutId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function startDaemonUnlocked(
|
|
32
|
+
options: { port?: string; provider?: string },
|
|
15
33
|
): Promise<void> {
|
|
16
34
|
const args: string[] = [];
|
|
17
35
|
const port = options.port || "8008";
|
|
18
36
|
const pollIntervalMs = 250;
|
|
19
37
|
const startupTimeoutMs = 10 * 60 * 1000;
|
|
20
38
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const existing = await fetch(`http://localhost:${port}/health`, {
|
|
25
|
-
signal: controller.signal,
|
|
26
|
-
});
|
|
27
|
-
clearTimeout(timeoutId);
|
|
28
|
-
if (existing.ok) {
|
|
29
|
-
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
} catch {
|
|
33
|
-
// Daemon is not running yet; continue with startup.
|
|
39
|
+
if (await isDaemonHealthy(port)) {
|
|
40
|
+
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
41
|
+
return;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
if (options.port) {
|
|
@@ -47,7 +55,7 @@ export async function startDaemon(
|
|
|
47
55
|
|
|
48
56
|
const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
|
|
49
57
|
const todayLogFile = getTodayLogFile();
|
|
50
|
-
const shellCmd = `bun run "${daemonScript}" ${args.map(a => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
|
|
58
|
+
const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")} >> "${todayLogFile}" 2>&1`;
|
|
51
59
|
|
|
52
60
|
const proc = Bun.spawn(["sh", "-c", shellCmd], {
|
|
53
61
|
stdout: "inherit",
|
|
@@ -73,19 +81,9 @@ export async function startDaemon(
|
|
|
73
81
|
);
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const res = await fetch(`http://localhost:${port}/health`, {
|
|
80
|
-
signal: controller.signal,
|
|
81
|
-
});
|
|
82
|
-
clearTimeout(timeoutId);
|
|
83
|
-
if (res.ok) {
|
|
84
|
-
logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
} catch {
|
|
88
|
-
// Not ready yet
|
|
84
|
+
if (await isDaemonHealthy(port)) {
|
|
85
|
+
logger.log(`Routstr daemon started (PID: ${proc.pid}).`);
|
|
86
|
+
return;
|
|
89
87
|
}
|
|
90
88
|
}
|
|
91
89
|
|
|
@@ -93,3 +91,27 @@ export async function startDaemon(
|
|
|
93
91
|
`Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`,
|
|
94
92
|
);
|
|
95
93
|
}
|
|
94
|
+
|
|
95
|
+
export async function startDaemon(
|
|
96
|
+
options: { port?: string; provider?: string } = {},
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const port = options.port || "8008";
|
|
99
|
+
const startupTimeoutMs = 10 * 60 * 1000;
|
|
100
|
+
|
|
101
|
+
if (await isDaemonHealthy(port)) {
|
|
102
|
+
logger.log(`Routstr daemon already running on http://localhost:${port}/v1`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await withCrossProcessLock(
|
|
107
|
+
DAEMON_STARTUP_LOCK_PATH,
|
|
108
|
+
async () => {
|
|
109
|
+
await startDaemonUnlocked(options);
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
acquireTimeoutMs: startupTimeoutMs + 30_000,
|
|
113
|
+
staleAfterMs: startupTimeoutMs + 30_000,
|
|
114
|
+
log: (message) => logger.debug(message),
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
+
import { startDaemon } from "../start-daemon";
|
|
2
3
|
import {
|
|
3
4
|
CONFIG_FILE,
|
|
4
5
|
DEFAULT_CONFIG,
|
|
5
|
-
LOGS_DIR,
|
|
6
6
|
type RoutstrdConfig,
|
|
7
7
|
} from "./config";
|
|
8
8
|
import {
|
|
@@ -115,31 +115,11 @@ export function getNpubSuffix(config: RoutstrdConfig): string | null {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
export async function startDaemonProcess(): Promise<void> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const proc = Bun.spawn(
|
|
124
|
-
["bun", "run", `${import.meta.dir}/../daemon/index.ts`],
|
|
125
|
-
{
|
|
126
|
-
stdout: "inherit",
|
|
127
|
-
stderr: "inherit",
|
|
128
|
-
stdin: "ignore",
|
|
129
|
-
detached: true,
|
|
130
|
-
},
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
proc.unref();
|
|
134
|
-
|
|
135
|
-
for (let i = 0; i < 50; i++) {
|
|
136
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
137
|
-
if (await isDaemonRunning()) {
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
throw new Error("Daemon failed to start within 5 seconds");
|
|
118
|
+
const config = await loadConfig();
|
|
119
|
+
await startDaemon({
|
|
120
|
+
port: String(config.port || 8008),
|
|
121
|
+
provider: config.provider || undefined,
|
|
122
|
+
});
|
|
143
123
|
}
|
|
144
124
|
|
|
145
125
|
export async function ensureDaemonRunning(): Promise<void> {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { mkdir, readFile, rm, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
|
|
5
|
+
export interface CrossProcessLockOptions {
|
|
6
|
+
/** How long to wait while another process holds the lock. */
|
|
7
|
+
acquireTimeoutMs?: number;
|
|
8
|
+
/** How often to retry acquiring the lock. */
|
|
9
|
+
retryIntervalMs?: number;
|
|
10
|
+
/** Treat locks older than this as stale even if their PID cannot be checked. */
|
|
11
|
+
staleAfterMs?: number;
|
|
12
|
+
/** Optional logger used when removing stale locks. */
|
|
13
|
+
log?: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface LockOwner {
|
|
17
|
+
pid: number;
|
|
18
|
+
createdAt: number;
|
|
19
|
+
token?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function delay(ms: number): Promise<void> {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isProcessRunning(pid: number): boolean {
|
|
27
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
36
|
+
return code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readLockOwner(lockDir: string): Promise<LockOwner | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await readFile(`${lockDir}/owner.json`, "utf8");
|
|
43
|
+
const parsed = JSON.parse(raw) as Partial<LockOwner>;
|
|
44
|
+
if (
|
|
45
|
+
typeof parsed.pid === "number" &&
|
|
46
|
+
typeof parsed.createdAt === "number"
|
|
47
|
+
) {
|
|
48
|
+
return {
|
|
49
|
+
pid: parsed.pid,
|
|
50
|
+
createdAt: parsed.createdAt,
|
|
51
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// The lock may have been created but not fully written yet.
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function isLockStale(
|
|
61
|
+
lockDir: string,
|
|
62
|
+
staleAfterMs: number,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const owner = await readLockOwner(lockDir);
|
|
65
|
+
if (owner) {
|
|
66
|
+
return !isProcessRunning(owner.pid) || Date.now() - owner.createdAt > staleAfterMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(lockDir);
|
|
71
|
+
return Date.now() - info.mtimeMs > staleAfterMs;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function acquireCrossProcessLock(
|
|
78
|
+
lockDir: string,
|
|
79
|
+
options: CrossProcessLockOptions = {},
|
|
80
|
+
): Promise<() => Promise<void>> {
|
|
81
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? 120_000;
|
|
82
|
+
const retryIntervalMs = options.retryIntervalMs ?? 100;
|
|
83
|
+
const staleAfterMs = options.staleAfterMs ?? 120_000;
|
|
84
|
+
const deadline = Date.now() + acquireTimeoutMs;
|
|
85
|
+
|
|
86
|
+
await mkdir(dirname(lockDir), { recursive: true });
|
|
87
|
+
|
|
88
|
+
while (true) {
|
|
89
|
+
try {
|
|
90
|
+
await mkdir(lockDir);
|
|
91
|
+
const token = randomUUID();
|
|
92
|
+
const owner: LockOwner = { pid: process.pid, createdAt: Date.now(), token };
|
|
93
|
+
await writeFile(`${lockDir}/owner.json`, JSON.stringify(owner), "utf8");
|
|
94
|
+
let released = false;
|
|
95
|
+
return async () => {
|
|
96
|
+
if (released) return;
|
|
97
|
+
released = true;
|
|
98
|
+
|
|
99
|
+
const currentOwner = await readLockOwner(lockDir);
|
|
100
|
+
if (currentOwner?.token === token) {
|
|
101
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
106
|
+
if (code !== "EEXIST") {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (await isLockStale(lockDir, staleAfterMs)) {
|
|
111
|
+
options.log?.(`Removing stale lock at ${lockDir}`);
|
|
112
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Date.now() >= deadline) {
|
|
117
|
+
throw new Error(`Timed out waiting to acquire lock ${lockDir}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await delay(retryIntervalMs);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function withCrossProcessLock<T>(
|
|
126
|
+
lockDir: string,
|
|
127
|
+
fn: () => Promise<T>,
|
|
128
|
+
options: CrossProcessLockOptions = {},
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
const release = await acquireCrossProcessLock(lockDir, options);
|
|
131
|
+
try {
|
|
132
|
+
return await fn();
|
|
133
|
+
} finally {
|
|
134
|
+
await release();
|
|
135
|
+
}
|
|
136
|
+
}
|