Skip to content

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

Description

@TerrifiedBug

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()undefinedensureDaemon 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 ensureOwnTopiccreateForumTopic (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

  1. Read the lock before spawning, not after. The poller lock is a local file; daemonDisableReason can consult it and return a disable reason in-process. Spawning a child to discover something already readable is what makes this expensive.
  2. 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.
  3. A daemon child must never claim a session topic. Pass a flag/env that suppresses interactive session activation, or gate ensureOwnTopic on being an interactive session. A background helper owning a human-visible topic is wrong even when it succeeds.
  4. Bound the retry. No backoff and no failure memory turns one persistent wrong condition into unbounded permanent side effects.
  5. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions