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.
@@ -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, "daemon"], env);
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 daemon exited early with code ${exitCode}`);
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 daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds`,
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
- logger.debug(`Starting cocod daemon via ${executable}...`);
264
- startPromise = startDaemon().finally(() => {
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
  }
@@ -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
- export async function startDaemon(
14
- options: { port?: string; provider?: string } = {},
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
- try {
22
- const controller = new AbortController();
23
- const timeoutId = setTimeout(() => controller.abort(), 2000);
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
- try {
77
- const controller = new AbortController();
78
- const timeoutId = setTimeout(() => controller.abort(), 2000);
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
- // Ensure logs directory exists (logger handles date-based files)
119
- if (!existsSync(LOGS_DIR)) {
120
- await Bun.$`mkdir -p ${LOGS_DIR}`;
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
+ }