Skip to content

fix(core): rebase directory watcher events across symlinked roots - #54093

Closed
kitlangton wants to merge 1 commit into
devfrom
plugin-registry
Closed

kitlangton wants to merge 1 commit into
devfrom
plugin-registry

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Why

When OpenCode watches a config or plugin directory reached through a symlink (such as macOS temp roots under /var/folders → /private/var/folders or /tmp → /private/tmp, symlinked project checkouts, or a symlinked ~/.config/opencode dotfiles directory), @parcel/watcher on macOS (fs-events) emits canonical realpath event paths while Config entries retain the subscribed directory spelling. Domain subscribers (ConfigPluginSource, ConfigCommandPlugin, ConfigAgentPlugin) filter config.changes() with FSUtil.contains(path.join(entry.path, ...), update.path), which returns false across the two spellings and silently drops live additions, edits, and removals (causing the plugin-registry Drive regression probe to time out waiting for a newly written .opencode/plugins/beta/index.ts to activate). In addition, @parcel/watcher/wrapper.js resolves literal ignore entries (node_modules, .git) against the unresolved directory string before FSEvents canonicalizes the watched root, so ignorePaths fails to match under symlinked directories on macOS, and @parcel/watcher's inotify backend does not traverse a symlinked root directory unless resolved upfront.

What Changes

Scenario Before After
Directory watch subscribed at symlinked path directory (e.g. /var/folders/.../.opencode) @parcel/watcher receives unresolved directory; macOS fs-events emits /private/var/folders/... paths that fail FSUtil.contains(entry.path, update.path), and Linux inotify skips symlinked target roots Watcher resolves resolved = FSUtil.resolve(directory) for @parcel/watcher and rebases emitted event paths under resolved back onto directory
Literal ignore entries (node_modules, .git) under a symlinked directory @parcel/watcher resolves ignorePaths against the unresolved path, missing canonical FSEvents event paths @parcel/watcher receives resolved, so ignorePaths and native events share the canonical prefix across all backends
Runtime plugin/command/agent file changes under a symlinked config root ConfigPluginSource, ConfigCommandPlugin, and ConfigAgentPlugin drop config.changes() events update.path matches entry.path, triggering live reloads without a server restart
flowchart LR
  Sub["Watcher.subscribe({ path: directory, type: \"directory\", ignore })"] --> Res["resolved = FSUtil.resolve(directory)"]
  Res --> Parcel["native.subscribe(resolved, callback, { ignore, backend })"]
  Parcel --> Ev["Native event: update.path under resolved"]
  Ev --> Rebase["Rebase update.path from resolved onto directory"]
  Rebase --> Match["FSUtil.contains(entry.path, update.path) === true"]
Loading

Scope

Owns canonical target resolution and event path rebasing for recursive directory watches in Watcher.nativeLayer. Single-file and immediate-entry watches (node:fs.watch) already construct event paths from input.target.

Verification

# Drive TUI regression probe on macOS (FSEvents under default /var/folders -> /private/var/folders TMPDIR)
bun run --cwd packages/drive drive start \
  --name mac-rev54093-head \
  --script test/manual/tui-regressions/plugin-registry.ts \
  --dev /path/to/opencode

# Focused watcher and command reload suites
bun run --cwd packages/core test \
  test/filesystem/watcher.test.ts \
  test/config/command.test.ts

# Full core suite and repository-wide lint, ast-grep, sdk-docs, and 36-package typecheck
bun run --cwd packages/core test
bun run check
bun run lint:changed origin/v2
  • Before (origin/v2 on macOS FSEvents under default /var/folders TMPDIR):
    • plugin-registry Drive probe: Phase 1 (alpha cold start) passes, Phase 2 (fs.writeFile(".opencode/plugins/beta/index.ts", ...)) times out after 20s with plugin-registry failed: Error: timed out: plugins alpha,beta without -.
    • New symlinked-root tests in test/filesystem/watcher.test.ts and test/config/command.test.ts fail (time out waiting for rebased watch events) on both macOS (fs-events) and Linux (inotify).
  • After (PR head on macOS FSEvents and Linux inotify):
    • plugin-registry Drive probe completes all three phases (cold start with alpha, runtime addition of beta, runtime removal of beta) with {"requests":3} on macOS (TMPDIR=/var/folders/...) and on Linux (both default /tmp and symlinked TMPDIR).
    • packages/core full suite: 6606 pass, 33 skip, 0 fail across 261 files.
    • bun run check: 36/36 packages pass typecheck and lint.

@cbrachem

Copy link
Copy Markdown

Nice. I noticed today that hot reloading config does not work when the config dir is a symlink. I was just about to bring a similar PR when I learned you already fixed it here. :)

@Veld101

Veld101 commented Oct 17, 2026

Copy link
Copy Markdown

Independent confirmation of the rebase half, from a fix that tried to get away with resolution alone.

I opened #56294 to fix #54933 by resolving each watched directory inside ConfigWatch.plan, without touching Watcher. It passed its own tests and was wrong: it makes the subscription start, but events then arrive under the resolved path while Config entries keep the discovered spelling, so the filters drop them. Same broken hot-reload, quieter failure:

entry.path : /tmp/…/link/agents           ← what Config keeps (sources.global is unresolved)
event.path : /tmp/…/real/agents/review.md ← what a resolved watch emits
FSUtil.contains(entry, event) = false

That is agent.ts:137, command.ts:152 and source.ts:195 in packages/core/src/config/plugin/. Closing #56294 in favour of this PR is right — this one owns both halves, and keeping resolution in Watcher also keeps the ignore paths canonical, which a plan-level change does not.

Two things from that attempt that may be useful for this PR's tests:

The refused subscription is a concrete failure, not just missing events. On a real inotify backend, subscribing to the link is rejected outright:

[symlink] subscribe FAILED: inotify_add_watch on '/tmp/ino-xxx/link' failed: Not a directory
[real]    event: [["update", "real/a.json"]]

So a test can distinguish "subscription failed" from "event dropped by a filter" — they fail the same way downstream (no reload) but for different reasons, and #54933's log shows the first while the filter bug produces the second.

A watch-level assertion is not enough. My test subscribed to the planned path, wrote through the link, and asserted the emitted update.path. It passed while the reload path was still broken, because it never went through Config.changes() and a subscriber's FSUtil.contains filter. Asserting the rebased update.path equals the subscribed spelling — and, ideally, that a real config edit actually reloads — would catch a resolution-only regression.

The rebased spelling is the part my attempt missed, so I assume your new test in test/filesystem/watcher.test.ts pins it; flagging in case a Config-level case is worth adding too.

@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants