Skip to content

fix: resolve symlinks in Instance cache to prevent duplicate contexts#16650

Closed
jmylchreest wants to merge 4 commits intoanomalyco:devfrom
jmylchreest:fix/tui-sync-race
Closed

fix: resolve symlinks in Instance cache to prevent duplicate contexts#16650
jmylchreest wants to merge 4 commits intoanomalyco:devfrom
jmylchreest:fix/tui-sync-race

Conversation

@jmylchreest
Copy link

Issue for this PR

Closes #16648
Fixes #16647
Fixes #15482
Related: #16522

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

When opencode runs from a symlinked directory, Instance.provide() creates duplicate contexts for the same physical directory. Filesystem.resolve() uses path.resolve() which normalizes ./.. segments but does not resolve symlinks. Two different path strings for the same physical directory produce two separate Instance entries in the cache.

Because the Bus pub/sub system is Instance-scoped (via Instance.state()), events published on one Instance (where the LLM session runs) are never received by subscribers on the other Instance (where the SSE endpoint listens). This causes a completely blank TUI after sending prompts.

Changes:

  1. instance.ts: Add fs.realpathSync() before Filesystem.resolve() in both provide() and reload() so symlinks resolve to canonical paths before cache lookup.

  2. sync.tsx: Harden the sync() merge logic so fetched session data is merged with any event-delivered data rather than overwriting it. This prevents a race where real-time events arriving during the async fetch window get discarded.

  3. prompt/index.tsx: Updated comment on the 50ms navigation delay to reflect the current understanding.

How did you verify your code works?

  • Reproduced the bug by running opencode from ~/src-office (a symlink to ~/dtkr4-cnjjf/...)
  • Confirmed blank TUI after sending prompt (events never bridged between instances)
  • Applied fix, rebuilt, and verified:
    • Prompts display immediately
    • Assistant responses stream in real-time
    • Resume (-c) continues to work correctly
  • Ran bun tsc --noEmit — no type errors

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Add configurable cleanup module that runs on startup to manage database
size and remove orphaned storage files. Fixes log cleanup bug where
non-deterministic glob ordering could delete newest files instead of
oldest.

Changes:
- New cleanup config stanza with session retention, storage sweep, and vacuum options
- Fix log cleanup: sort by filename before slicing, align guard with slice count
- Sweep orphaned storage files (session, message, part, todo, session_diff, project, snapshot)
- Prune empty directories after sweep
- Run SQLite VACUUM and WAL checkpoint on startup
- Fix pre-existing path.sep bug in Storage.list() for cross-platform correctness
- Defer cleanup 500ms and yield every 100 deletions to avoid blocking TUI
The `@opencode-ai/script` import runs top-level shell commands that
leave handles open, preventing the process from exiting naturally
after Bun.build completes.
When opencode runs from a symlinked directory, Instance.provide() could
create two separate contexts for the same physical directory because
path.resolve() does not resolve symlinks. This caused Bus event isolation:
events published on one Instance never reached subscribers on the other,
resulting in a completely blank TUI after sending prompts.

Add fs.realpathSync() to canonicalize paths before using them as Instance
cache keys. Also harden the sync() merge logic so that fetched data is
merged with any event-delivered data rather than overwriting it.

Closes anomalyco#16648
@github-actions
Copy link
Contributor

github-actions bot commented Mar 8, 2026

The following comment was made by an LLM, it may be inaccurate:

I found a potentially related PR:

PR #15483: "fix: symlink path resolution causing duplicate instances (#15482)"
#15483

Why it might be related:

You may want to check if #15483 was closed/rejected in favor of this new approach, or if they complement each other.

@jmylchreest
Copy link
Author

Closing — this PR accidentally included unrelated commits from a local merge branch. Reopening as a clean PR from a branch based on dev.

@jmylchreest jmylchreest closed this Mar 8, 2026
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
// Resolve symlinks so the same physical directory always maps to one Instance.
const directory = Filesystem.resolve(fs.realpathSync(input.directory))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.realpathSync() throws ENOENT if the path doesn't exist. Filesystem.resolve() (via path.resolve()) previously handled non-existent paths gracefully.

This is a behavioral change that could surface as an unhandled exception in edge cases (e.g., a directory removed between CLI invocation and Instance.provide()).

Consider guarding:

Suggested change
const directory = Filesystem.resolve(fs.realpathSync(input.directory))
const directory = Filesystem.resolve(fs.existsSync(input.directory) ? fs.realpathSync(input.directory) : input.directory)

Or wrapping in try/catch to fall back to the unresolved path, matching the pattern already used in

normalizePath():
function realpath(p: string): string {
  try {
    return fs.realpathSync(p)
  } catch {
    return p
  }
}

},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = Filesystem.resolve(input.directory)
const directory = Filesystem.resolve(fs.realpathSync(input.directory))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same realpathSync throw risk as provide() above — should use the same guard pattern.

Suggested change
const directory = Filesystem.resolve(fs.realpathSync(input.directory))
const directory = Filesystem.resolve(fs.existsSync(input.directory) ? fs.realpathSync(input.directory) : input.directory)

import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
import fs from "fs"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since symlink resolution is now an explicit concern of Instance, it might be cleaner to push this into Filesystem.resolve() itself — it already handles Windows path translation and normalizePath. That way all callers get symlink resolution, not just provide() and reload().

Something like:

// filesystem.ts
export function resolve(p: string): string {
  const resolved = pathResolve(windowsPath(p))
  try {
    return normalizePath(realpathSync(resolved))
  } catch {
    return normalizePath(resolved)
  }
}

This would also cover server.ts middleware and thread.ts, which both call Filesystem.resolve() independently and could theoretically still produce non-canonical paths.

export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = Filesystem.resolve(input.directory)
// Resolve symlinks so the same physical directory always maps to one Instance.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fix has no test coverage. A symlink-specific test for Instance.provide() would prevent regressions — e.g.:

test("preserves single instance for symlinked directory", async () => {
  if (process.platform === "win32") return
  await using tmp = await tmpdir({ git: true, config: { model: "test/model" } })
  const link = tmp.path + "-symlink"
  await fs.symlink(tmp.path, link)
  try {
    await Instance.provide({
      directory: link,
      fn: async () => {
        const config = await Config.get()
        expect(config.model).toBe("test/model")
      },
    })
  } finally {
    await fs.unlink(link).catch(() => {})
  }
})

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

Labels

None yet

Projects

None yet

2 participants