Skip to content

fix: a daemon spawn either becomes a daemon or says why (#68) - #69

Merged
TerrifiedBug merged 1 commit into
masterfrom
fix/daemon-spawn-loop
Aug 23, 2026
Merged

fix: a daemon spawn either becomes a daemon or says why (#68)#69
TerrifiedBug merged 1 commit into
masterfrom
fix/daemon-spawn-loop

Conversation

@TerrifiedBug

Copy link
Copy Markdown
Owner

Closes #68. Also removes the reason #67's /cleanup could not clear the mess it exists for.

What happened

Installing 0.12.2 on a host whose omp session held the poll lock produced 83 permanent Telegram topics, ~5s apart, across three bursts. Stopping the bridge did not help. Three defects compounded.

1. The spawn could never have worked

spawnDaemon(process.execPath, [join(import.meta.dirname, "daemon.ts")], )

Inside the omp binary process.execPath is omp — a compiled Bun executable that ignores a script argument and boots an interactive agent session. Proof, on the affected host:

$ omp …/omp-telegram/src/daemon.ts   → 93 bytes, exit 129
$ omp /nonexistent/path/xyz.ts       → 93 bytes, exit 129
$ cmp                                → IDENTICAL

Byte-identical to a path that does not exist. daemon.ts never ran from an omp host; every "spawn" was a fresh session that claimed a topic during plugin activation, then exited. return "spawned" was a lie, and the daemon only ever started from hosts where execPath really was a runtime — which is why one had been running as 0.12.1 until the upgrade SIGTERMed it.

Now: the runtime is resolved by name; the host binary counts only when it is one; no runtime means no spawn. Launching something that cannot parse the entrypoint is the same silent no-op in a different costume.

2. Nothing recorded the decline

runDaemon correctly exits when another process owns the poll lock:

[telegram daemon] another poller (pid 2186015, conductor) holds the lock; exiting

…and writes no daemon.json — by design, since that file must always name the live poller. But ensureDaemon consulted only that file, so "no daemon yet" and "a daemon is unnecessary" were indistinguishable. Every caller re-ran the same experiment, and each experiment cost a topic.

daemonDisableReason already had the right shape for configured reasons (bridge disabled, topics off, groups configured) — returning early without spawning. A runtime reason had no equivalent.

The lock is a local file, so it is now read before spawning and a live foreign owner returns a new declined. Ordering is deliberate: the stale-version kill still happens first, or a daemon holding its own lock would be immortal — pinned by a test.

Both existing call sites already do the right thing with declined: index.ts:812 falls through to acquireAndLaunch, so the session polls for itself, which is exactly correct when no daemon can run.

3. The blast radius was unbounded

A process launched to be the daemon is not a conversation and has no business owning a human-visible topic. The launcher now marks the child and ensureTopic refuses. This should be unreachable after (1) — it is here because the marker costs nothing and being wrong cost 83 topics.

And why /cleanup couldn't fix it

const tmp = `${file}.tmp`;          // shared across processes
writeFileSync(tmp, ); renameSync(tmp, file);

claimThread was an unserialised read-modify-write over a whole file, through a shared temp that two writers could both rename. Concurrent claims silently lost rows: a burst that created 16 topics recorded 15. (saveDaemonState already used ${path}.tmp-${process.pid}; this did not.)

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 find again. /cleanup reads staleThreads, which reads the registry, so those topics were permanently unreachable. 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 (a caller that cannot take the lock in 2s proceeds anyway; a hung startup is worse than a lost update) and self-healing by age.

Tests — each checked against the old code first

test reverting… result
declines instead of spawning when another live process owns the poll lock the lock precheck fails ("spawned")
concurrent claims from separate processes all persist the write path fails (a row lost)

Both go green with the fix. The rest: a dead lock holder must not wedge it shut; our own lock is not foreign; a stale-version daemon is still stopped before the lock is consulted; the resolved runtime is launched and the child is marked; resolveRuntime rejects a host binary that only looks like a launcher, accepts one that is, and finds bun on PATH otherwise; a dead mutation lock self-heals; no temp files leak.

bun run check   → 300 pass, 0 fail   (was 288)

Not in this PR

#67's other half — /cleanup keys on dead-pid alone and, in a DM host, deletes. So its only remedy for 83 topics minutes old would also have permanently destroyed an unrelated project topic from eight days earlier. That wants an age/reason predicate and a partial /cleanup go, and it is a separate change.

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.
@TerrifiedBug
TerrifiedBug merged commit 932d1ab into master Aug 23, 2026
1 check passed
@TerrifiedBug
TerrifiedBug deleted the fix/daemon-spawn-loop branch August 23, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ensureDaemon spawns an omp session to discover it should not run, and each attempt mints a permanent topic — unbounded after an upgrade

1 participant