Skip to content

Plugins are silently unloaded after a plugin-dir symlink swap is followed by any rescan — NixOS home-manager deployments #59725

Closed as duplicate

Description

@luochen1990

Description

On a long-running opencode serve --service daemon, swapping the <name> symlink inside ~/.config/opencode/plugin/ (routine on NixOS: home-manager activation repoints every plugin symlink to a new /nix/store/... path) leaves the plugin running stale code — and the next rescan silently unloads it: hooks are disposed, /api/plugin stops listing the plugin, and no plugin-lifecycle log line is emitted (the only log noise is the known-unrelated #53210 watcher error). We only noticed because the tool hook our PR workflow depends on stopped firing after deployments; the logs gave us nothing to go on.

Per our source read at the pinned version below: the plugin entry is resolved once at boot via Bun.resolveSync and cached process-wide, anchored to the then-current realpath (stale after the swap). Any later rescan computes a fresh realpath(symlink) and runs a containment check against that cached resolution; the check can never match, the plugin operation is silently dropped, and the registry treats the plugin as deleted and unloads it.

Two isolation experiments narrow it down:

  1. Swapping the symlink alone does not kill the plugin — old code keeps running.
  2. The swap plus any later rescan silently unloads it. Re-pointing the symlink back at the original cached target revives the plugin on the next rescan, which strongly supports the mismatch reading; a full restart after re-pointing at the new target loads the new code correctly.

Note: the plugin config subsystem has been refactored since our pin (we could not find the same code path on current HEAD), so this may already be fixed or reshaped — reporting our fully reproduced evidence in case the failure mode survives the refactor. Related but different layer: #46551 (config parse drops file paths at startup; ours is a runtime rescan unloading a previously working plugin).

Plugins

Custom local plugin (a tool hook that injects a session deep-link into forgejo PR/issue bodies), installed as a directory symlink under ~/.config/opencode/plugin/.

OpenCode version

2.0.26 (commit 0d61729b3f), packaged via Nix on our side

Steps to reproduce

Isolated environment (fresh $XDG_* dirs — the client commands below run with the same $XDG_* so they hit the isolated daemon; an OpenAI-compatible mock provider so tool calls are deterministic). The probe plugin carries a MARKER constant and its hook appends a line with that MARKER to a hook.log file (a path of your choice, e.g. $PWD/hook.log), so you can tell which generation of code is live.

Layout (all under an env root you cd into):

env/
├── config/opencode/plugin/probe -> /abs/path/env/store-A/probe   (symlink)
├── store-A/probe/   (index.js + package.json, MARKER=A)
└── store-B/probe/   (identical shape, MARKER=B)
  1. Create the two plugin generations and the symlink as shown (use an absolute symlink target).
  2. Start opencode serve --service against the isolated XDG, open a session, invoke any tool — hook.log gains a MARKER=A line; /api/plugin lists probe.
  3. Swap the symlink: ln -sfn "$PWD/store-B/probe" config/opencode/plugin/probe. Invoke a tool again — the hook still fires with MARKER=A (stale but alive). (On a platform where the directory watcher works, the unload may happen right here instead of waiting for step 4.)
  4. Trigger a rescan by touching the old entry file that the daemon already loaded (touch store-A/probe/index.js — per-file watches still work on NixOS, see NixOS: native recursive FS watcher never loads (libstdc++.so.6 not found) #53210), then invoke a tool: the hook no longer fires, /api/plugin no longer lists probe, and no plugin-lifecycle log line was emitted at any point. (In production the same unload is reached via the supervisor's periodic full rescan, up to ~24h later.)
  5. Swap the symlink back to store-A/probe and touch the entry again — the plugin comes back (MARKER=A, setup re-runs), confirming the stale-realpath reading.
  6. Point the symlink at store-B/probe again and opencode service restart — the plugin loads with MARKER=B (new code), as expected.

Expected Behavior

After the symlink swap plus a rescan, the plugin at the new target should be loaded (new code active). At minimum, dropping a previously-working plugin should produce a warning — a silent unload is the worst outcome for a long-running service.

Actual Behavior

The plugin is silently unloaded; hooks stop firing; nothing in the logs until a full restart.

Screenshot

n/a (log silence is the symptom)

Operating System

NixOS 26.11 (x86_64); plugin dirs managed by home-manager (symlink swap on every activation)

Terminal

Alacritty (daemon context: systemd user service)

Activity

  1. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the very thorough report and the isolation experiments. I think this is a duplicate of #53763, which reports the same problem and has already been reproduced. Retargeting a directory-symlinked local plugin (nix/home-manager style) makes the next plugin rescan drop it silently. The cause is that Bun.resolveSync keeps the old target cached while a fresh realpath sees the new one, so the containment check in the plugin source scan rejects the plugin, with no failed entry and no log line.

    Possible duplicates and related issues, ordered from most to least likely:

    Your confirmation on 2.0.26, and the finding that pointing the symlink back revives the plugin, would be useful on #53763. The bot will close this issue as a duplicate in 1 day unless you reply explaining how your problem is different.

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    We haven't heard back, so I'm closing this as a duplicate of #53763. That issue tracks the same problem: retargeting a directory-symlinked local plugin (as home-manager does) makes the next rescan drop the plugin silently, because the cached Bun.resolveSync result and a fresh realpath disagree. Please follow #53763 for updates. Your 2.0.26 confirmation and the finding that pointing the symlink back revives the plugin would be useful there.

    If your case turns out to be different, reply here and we can reopen it. Thanks again for the detailed investigation.

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