Repository navigation
ensureDaemon spawns an omp session to discover it should not run, and each attempt mints a permanent topic — unbounded after an upgrade #68
Description
Summary
Upgrading to 0.12.2 put this host into an unbounded loop that minted one permanent Telegram topic every ~5 seconds — 40 in eleven minutes, then 40 more after the bridge was restarted. Stopping the bridge does not stop it.
The upgrade is the trigger. The defect is that a runtime decline to start is never recorded, and the retry vehicle is an omp session that claims a topic before it discovers it should exit.
The chain, each step verified on the host
1. The upgrade kills the running daemon — by design.
ensureDaemon is "ensure one current-version daemon is alive" (daemon.ts:106). On a version mismatch it SIGTERMs the incumbent (daemon.ts:120-133). Installing 0.12.2 therefore terminated the live 0.12.1 poller. Correct behaviour.
2. The slot can never be refilled, because a session holds the poller lock.
Running the daemon by hand shows the decline verbatim:
$ bun src/daemon.ts
[telegram daemon] another poller (pid 2186015, conductor) holds the lock; exiting
$ echo $?
0
pid 2186015 is a live interactive omp session. Telegram permits exactly one getUpdates consumer, so declining is right.
3. The decline leaves no trace, so it is re-litigated forever.
The declining child exits 0 and never writes daemon.json:
$ ls /root/.omp/agent/telegram/daemon.json
ls: cannot access …: No such file or directory
readDaemonState() → undefined → ensureDaemon skips the alive/version branch entirely and spawns. daemonDisableReason covers only configured reasons — bridge disabled, topics off, groups configured — and returns "disabled" without spawning. A runtime reason ("a poller already owns the lock") has no equivalent, so it is discovered only by spawning a child, and the answer is thrown away.
4. The retry vehicle has a permanent, user-visible side effect.
spawnDaemon(process.execPath, [join(import.meta.dirname, "daemon.ts")], { detached: true, … })Inside the omp binary, process.execPath is /root/.local/bin/omp — not bun. So the retry is omp <script>, which boots a full omp session: plugin activation runs ensureOwnTopic → createForumTopic (index.ts:1020) before the process reaches the lock check and exits.
Controlled single spawn, registry diffed either side:
before: ['9551']
$ timeout 8 /root/.local/bin/omp …/omp-telegram/src/daemon.ts # exit 129
after: ['9551', '10189'] #10189 conductor pid 2212517
One spawn, one permanent topic. The topic outlives the process by design; nothing reaps it (see #67).
5. It self-perpetuates and ignores the bridge.
Each spawned session itself calls ensureDaemon, spawning the next before exiting — detached, ppid 1, ~5s apart:
#10097 08:09:18 pid 2200460
#10100 08:09:23 pid 2200704 +5.0s
#10103 08:09:28 pid 2200944 +5.0s
…
Killing the whole chain at once is the only thing that stops it. Stopping the bridge daemon does not, because createForumTopic is called directly by each omp process with the bot token and never routes through the bridge — so the obvious containment does nothing, which cost real time here.
Why it surfaced only now
Before the upgrade the 0.12.1 daemon held the lock and ensureDaemon returned "alive" on the version match. The upgrade killed it, and the session that restarted moments later took the lock. From then on every spawn declines — the steady state flipped from "converges immediately" to "never converges", and the loop had always been latent behind it.
Suggested fixes, in order of value
- Read the lock before spawning, not after. The poller lock is a local file;
daemonDisableReasoncan consult it and return a disable reason in-process. Spawning a child to discover something already readable is what makes this expensive. - Record the negative result. Whatever the check, a decline should persist ("declined at T because the lock is held by pid N") so the next call is a no-op rather than a fresh experiment.
- A daemon child must never claim a session topic. Pass a flag/env that suppresses interactive session activation, or gate
ensureOwnTopicon being an interactive session. A background helper owning a human-visible topic is wrong even when it succeeds. - Bound the retry. No backoff and no failure memory turns one persistent wrong condition into unbounded permanent side effects.
- Reconsider
process.execPath. When the host binary is an agent,execPath <script>boots an agent. An explicit runtime, or an internal subcommand, says what it means.
Environment
omp-telegram 0.12.2, DM topics host, an interactive omp session holding the poller lock. Related: #67 (the orphans this produces are not reapable without deleting unrelated history).
Activity
fix: a daemon spawn either becomes a daemon or says why (#68) (#69)
TerrifiedBug commentedon Aug 23, 2026
Fixed by #69 (merged as
932d1ab).Verified on the affected host with the merged code deployed, the sentinel state file removed, and no
daemon.jsonpresent — i.e. the exact condition that produced 83 topics:Not yet published:
npm whoamireturns 401 on this host, so 0.12.3 needs an operator credential. Until then the installedsrc/*.tscarries the merged code whilepackage.jsonstill reads 0.12.2.chore: release 0.12.3 (#71)