Skip to content

plugins: event subscribers fire once per client connection — N× side effects, undocumented #56290

Open

Description

@liambreen

Summary

A plugin's setup() runs once per client connection in the same server process. Every ctx.event.subscribe() loop therefore receives every event N times (N = live connections). Plugins with event-driven side effects silently multiply their actions N×, and the plugins guide does not mention multi-instance semantics at all.

Environment

  • opencode version: 2.0.30
  • OS: Darwin 27.0.0 (darwin arm64)
  • Terminal: desktop app (Electron) + TUI
  • Shell: /bin/zsh
  • Install/channel: latest
  • Active plugins: local plugins under ~/.config/opencode/plugins/ — notably a Pushover notifier listening to session.execution.succeeded|failed|interrupted and permission.asked

Reproduction

  1. Create a local plugin that logs on every terminal event:
export default Plugin.define({
  id: "echo-test",
  async setup(ctx) {
    const id = Math.random().toString(36).slice(2);
    console.log("instance", id);
    void (async () => {
      for await (const event of ctx.event.subscribe({})) {
        if (event?.type === "session.execution.succeeded") {
          console.log("event received by", id);
        }
      }
    })();
  },
});
  1. Open the desktop app and let a few client connections stay alive (TUI / web / background clients all count).
  2. Finish a turn in any session.

Expected Behavior

  • Either one plugin instance per process (side effects fire once per event), or the plugins guide documents that setup() runs once per connection and shows the recommended cross-instance coordination pattern.

Actual Behavior

  • Server log shows repeated loading plugin lines — one per connection, each with a distinct http.span ID, all in the same run:
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4027083 role=server
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4871341 role=server
... msg="loading plugin" id=/…/plugins/opencode-notify entrypoint=file:///… http.span=4031979 role=server
... (one per live connection)
  • A per-instance diagnostic ledger (each instance writes its own storage key on receipt) shows ~5 instances starting within 14 ms and all receiving the same event in the same millisecond:
{"at":…,"instance":"fe1ng5l0","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"j0p04b96","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"gsr8ois8","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"xi6gpsxh","phase":"received","eventKey":"session.execution.failed:2221"}
{"at":…,"instance":"r6rzkmot","phase":"received","eventKey":"session.execution.failed:2221"}
  • The duplicate count tracks the live connection count over time (2×–5× observed across one session).
  • For a notification plugin this means 2–5 identical push notifications per event — every "agent finished its turn" pinged the user's phone up to 5 times.

Additional Context

  • Plugin lifecycle is fine: cleanup functions run per instance on reload and the AbortSignal passed to ctx.event.subscribe({ signal }) is honored — this is concurrent instantiation, not a leak.
  • Per-instance in-memory state cannot dedup across instances; only process-global state (e.g. a claim map on globalThis) can coordinate. That is the workaround we shipped and verified (one sender elected per event across ~5 instances).
  • permission.asked is ephemeral and carries no durable seq, which makes external dedup harder — a correlation ID on ephemeral events would help side-effect plugins.
  • Suggested docs fix: a "Multi-instance semantics" section in the plugins guide. Suggested product fix: an opt-in process-singleton mode (e.g. Plugin.define({ id, singleton: true })) or an official cross-instance coordination API.

Activity

  1. opencode-agent commented on Oct 17, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced this on 2.0.30, and it still happens on the latest dev (512165c).

    In my testing the multiplier is the number of loaded project directories (locations) in the server process, not the number of client connections:

    • I used a global local plugin that logs an instance id in setup() and on each session.created from ctx.event.subscribe({}), and ran one opencode serve.
    • I loaded three different directories, then created one session in the first. setup() ran 3 times, and all 3 instances received that same session.created.
    • When I loaded the same directory three times instead, there was only 1 instance and 1 delivery.

    From the code, plugins are started once per location, but ctx.event.subscribe() gives each instance the events from every location. That's why a notifier fires once per open project. Your globalThis claim map works as a workaround for now.

    The script below reproduces it without a model.

    Related: #54140 (instances pile up across config reloads, with the same N× symptom but a different cause) and #51003 (MCP servers started once per loaded directory).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #56290: a plugin's setup() runs once per loaded location in one server
    # process, and every instance's ctx.event.subscribe() receives every event.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC="bun --conditions=browser --preload=<repo>/packages/tui/node_modules/@opentui/solid/scripts/preload.js <repo>/packages/cli/src/index.ts" ./repro.sh
    set -u
    OC=${OC:-opencode}
    ROOT=$(mktemp -d /tmp/opencode/repro56290.XXXX)
    export HOME=$ROOT/home XDG_CONFIG_HOME=$ROOT/home/.config XDG_DATA_HOME=$ROOT/home/.local/share \
           XDG_STATE_HOME=$ROOT/home/.local/state XDG_CACHE_HOME=$ROOT/home/.cache
    LEDGER=$ROOT/ledger.log; export LEDGER
    mkdir -p $XDG_CONFIG_HOME/opencode/plugins $ROOT/p1 $ROOT/p2 $ROOT/p3
    for d in p1 p2 p3; do git -C $ROOT/$d init -q; done
    cat > $XDG_CONFIG_HOME/opencode/plugins/echo-test.js <<'JS'
    import { appendFileSync } from "node:fs"
    export default {
      id: "echo-test",
      async setup(ctx) {
        const id = Math.random().toString(36).slice(2, 8)
        appendFileSync(process.env.LEDGER, `setup ${id}\n`)
        void (async () => {
          for await (const event of ctx.event.subscribe({})) {
            if (event?.type === "session.created")
              appendFileSync(process.env.LEDGER, `event ${id} ${event.type} ${event.data?.sessionID ?? event.data?.id ?? JSON.stringify(event.data).slice(0, 60)}\n`)
          }
        })()
      },
    }
    JS
    export OPENCODE_PASSWORD=repro
    PORT=48${RANDOM:0:3}
    (cd $ROOT/p1 && $OC serve --port $PORT > $ROOT/server.log 2>&1) &
    SRV=$!
    for i in $(seq 60); do curl -s -o /dev/null http://127.0.0.1:$PORT/ && break; sleep 1; done
    # touch three different project directories (as three clients would)
    for d in p1 p2 p3; do
      curl -s -u opencode:repro -H "x-opencode-directory: $ROOT/$d" "http://127.0.0.1:$PORT/api/agent" > /dev/null
    done
    sleep 5
    echo "--- creating ONE session in p1"
    (cd $ROOT/p1 && $OC api --server http://127.0.0.1:$PORT POST /api/session -d '{}' | head -c 300; echo)
    sleep 3
    echo "--- ledger"; cat $LEDGER
    echo "--- setups: $(grep -c ^setup $LEDGER)  deliveries of the one session.created: $(grep -c ^event $LEDGER)"
    kill $SRV 2>/dev/null; wait 2>/dev/null

    Run it: bash repro.sh

  2. herrtreas commented on Nov 5, 2026

    @herrtreas

    Another effect of this, seen on 2.0.29 and 2.2.5: a session's hooks (session.prompt, session.title, tool.execute.*) fire only in the plugin instance for the session's own location, but its events reach every instance. A plugin that pairs hooks with events therefore misjudges other locations' sessions. In our case, an instance that never saw the session.title hook took a generated title for a name the person chose.

    Our workaround is to ignore events whose location (on session.created and session.moved, or from session.get) isn't the instance's own ctx.location, compared exactly, which is how 2.0.29 keys instances. The fixes in #56346 and #57186 would make that unnecessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions