From 268c156d7f3f5a5b7b137bd0c852414afbb2fe2b Mon Sep 17 00:00:00 2001 From: TerrifiedBug <35064668+TerrifiedBug@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:35:42 +0000 Subject: [PATCH] fix: a daemon spawn either becomes a daemon or says why (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing 0.12.2 on a host whose omp session held the poll lock produced 83 permanent Telegram topics, five seconds apart, and stopping the bridge did not help. Three defects compounded. **The spawn could never work.** `ensureDaemon` launched `process.execPath`, and inside the omp binary that is *omp* — a compiled Bun executable that ignores a script argument and boots an interactive agent session. Proof: `omp daemon.ts` and `omp /nonexistent/xyz.ts` write byte-identical output. So `daemon.ts` never ran from an omp host; every "spawn" was a fresh session that claimed a topic during plugin activation and exited. `return "spawned"` was a lie. The runtime is now resolved by name and the host binary counts only when it really is a runtime. No runtime, no spawn — because launching something that cannot parse the entrypoint is the same silent no-op in a different costume. **Nothing recorded the decline.** `runDaemon` correctly exits when another process owns the poll lock, and writes no `daemon.json` (by design — that file must always name the live poller). But `ensureDaemon` read only that file, so "no daemon" and "a daemon is unnecessary" were indistinguishable, and every caller re-ran the same experiment. The lock is a local file: it is now read before spawning, and a live foreign owner returns the new `declined`. Ordering is deliberate — the stale-version kill still happens first, or a daemon holding its own lock would be immortal. Both existing callers already do the right thing with `declined`: the session polls for itself. **And the blast radius was unbounded.** A process launched to be the daemon is not a conversation, so it now refuses to claim a topic when the launcher marks it. Belt and braces: the marker costs nothing and being wrong cost 83 topics. Separately, the same incident exposed why `/cleanup` could not clear the mess it exists for. `claimThread` was an unserialised read-modify-write over a whole file, through a *shared* `threads.json.tmp` that two writers could both rename — so concurrent claims silently lost rows. A burst that created 16 topics recorded 15. The registry is the only index of topics that exists (the Bot API cannot list them), so a lost row is a topic nobody can ever find again: 15 of the 83 had to be recovered by sweeping id ranges by hand. Writes are now per-process and serialised on the same `linkClaim` primitive the poll lock uses, bounded so a crashed holder cannot wedge startup and self-healing by age. Every test was checked against the old code first: reverting the lock precheck turns "declines instead of spawning" red, and reverting the write path turns "concurrent claims all persist" red. bun run check: 300 pass, 0 fail. --- src/api.ts | 9 ++- src/daemon.test.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++- src/daemon.ts | 85 ++++++++++++++++++++++++++-- src/index.ts | 7 +++ src/topics.test.ts | 50 +++++++++++++++++ src/topics.ts | 75 ++++++++++++++++++++++--- 6 files changed, 346 insertions(+), 17 deletions(-) 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