diff --git a/src/api.ts b/src/api.ts index e316651..d90951d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -309,7 +309,14 @@ function lockFresh(lockPath: string, freshMs: number): boolean { * not a racy read-then-write — decides the single winner, and the target is * populated the instant it appears (no empty mid-write window). */ -function linkClaim(target: string, pid: number, content: string = String(pid)): boolean { +/** + * Create `target` atomically, or report that somebody else already has. + * + * Exported so the thread registry can serialise its read-modify-write on the + * same primitive the poll lock uses (#68): two writers racing a shared + * `threads.json.tmp` published each other's file and silently lost claims. + */ +export function linkClaim(target: string, pid: number, content: string = String(pid)): boolean { const temp = `${target}.${pid}.${randomBytes(6).toString("hex")}`; writeFileSync(temp, content, { mode: 0o600 }); try { diff --git a/src/daemon.test.ts b/src/daemon.test.ts index 2bec333..c026b92 100644 --- a/src/daemon.test.ts +++ b/src/daemon.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { defaultAccess, saveAccess, statePath } from "./access"; -import { daemonDisableReason, ensureDaemon, readDaemonState } from "./daemon"; +import { daemonDisableReason, ensureDaemon, type EnsureDaemonOptions, readDaemonState, resolveRuntime } from "./daemon"; const previousStateDir = process.env.OMP_TELEGRAM_STATE_DIR; const previousToken = process.env.TELEGRAM_BOT_TOKEN; @@ -82,3 +82,138 @@ describe("daemon upgrades", () => { expect(readDaemonState()).toEqual({ pid: 9876, version: "0.2.0", startedAt: 1 }); }); }); + +describe("daemon spawn preconditions (#68)", () => { + const enable = (): void => { + saveAccess({ ...defaultAccess(), enabled: true, topicsChat: "42" }); + process.env.TELEGRAM_BOT_TOKEN = "token"; + }; + const spy = () => { + const calls: Array<{ executable: string; env?: NodeJS.ProcessEnv }> = []; + return { + calls, + spawn: ((executable, _args, options) => { + calls.push({ executable, env: options.env }); + return { once: () => undefined, unref: () => {} }; + }) as NonNullable, + }; + }; + + test("declines instead of spawning when another live process owns the poll lock", () => { + // The whole defect. Before the fix this spawned a child to discover the + // lock was taken, and because the child was `omp