Skip to content

fix: reopen DB connection when its file is replaced at the same path (#925)#929

Closed
Sway-Chan wants to merge 1 commit into
colbymchenry:mainfrom
Sway-Chan:fix/reopen-replaced-db-925
Closed

fix: reopen DB connection when its file is replaced at the same path (#925)#929
Sway-Chan wants to merge 1 commit into
colbymchenry:mainfrom
Sway-Chan:fix/reopen-replaced-db-925

Conversation

@Sway-Chan

@Sway-Chan Sway-Chan commented Jun 19, 2026

Copy link
Copy Markdown

Problem

Fixes #925. After a project directory is removed and recreated at the same path — a git worktree remove + git worktree add, or rm -rf .codegraph && codegraph init — a long-lived MCP server keeps serving a stale snapshot for the life of the daemon:

  • The server opened …/.codegraph/codegraph.db and holds that file descriptor.
  • Removing the dir unlinks the inode, but the open fd keeps reading it (deleted-but-open).
  • The recreate / init / sync writes a new inode at the same path.
  • The server reads the old inode while codegraph sync writes the new one — they never meet, so search returns deleted symbols and misses new ones until the process is restarted.

Fix

Detect the inode swap and reopen, encapsulated at the connection layer and triggered at the per-tool-call chokepoint:

  • DatabaseConnection records its DB file's (dev, ino) at open and exposes isFileReplaced() — true only when the path now resolves to a different inode. A missing file (mid-recreate) returns false, so a transient gap never churns the connection.
  • CodeGraph.isDbReplaced() surfaces it.
  • ToolHandler.getCodeGraph() is the one place every tool call resolves an instance, so the check lives there:
    • cross-project (projectPath) cache hits route through liveCachedGraph(), which evicts + closes a replaced entry so the next query reopens against the new inode via the existing open path;
    • the default project is reopened through a hook the MCPEngine registers (setDefaultReloadHook). The engine owns the default instance's watcher + catch-up sync, so the reopen is delegated to it — reusing the existing close → openSync → watch → catch-up recovery, opening the replacement before closing the stale handle so a failed reopen leaves the current connection in place.

The earlier draft only checked on the cold init methods (ensureInitialized / retryInitializeSync), but a loaded daemon never re-enters those — session.ts and the in-process proxy both short-circuit once a default is loaded — so the default-project case (the headline scenario) would not have fired at runtime. Driving it from getCodeGraph fixes that. The check is one fs.statSync per tool call, between requests, so it never races an in-flight query.

Testing

New __tests__/mcp-db-reopen.test.ts:

  • DatabaseConnection.isFileReplaced() — false when unchanged, true after a same-path inode swap, false while the file is missing.
  • CodeGraph.isDbReplaced() — flips to true after the project DB is recreated at the same path.
  • ToolHandler.liveCachedGraph() — a replaced cached (cross-project) project is evicted and closed; an unchanged one is returned as-is.
  • ToolHandler.getCodeGraph() default path — a swapped default project fires the engine reload hook and serves the fresh instance.

tsc --noEmit clean; full test suite green (1580 passed).

…ry#925)

After a project dir is removed and recreated at the same path (a
`git worktree remove`+`add`, or a fresh `codegraph init`), a long-lived
MCP server kept reading the old, now-unlinked inode while init/sync wrote
to the new one — serving a stale snapshot for the life of the daemon, with
`codegraph sync` unable to refresh it.

DatabaseConnection records its DB file's (dev, ino) at open and exposes
isFileReplaced(); CodeGraph surfaces it as isDbReplaced(). Detection runs at
the per-tool-call chokepoint, ToolHandler.getCodeGraph:
  - cross-project (projectPath) cache hits evict + close a replaced entry so
    the next query reopens against the new inode;
  - the default project is reopened via a hook the MCPEngine registers (it
    owns the default instance's watcher + catch-up), driven from the
    default-serving path rather than the cold init methods, which a loaded
    daemon never re-enters.

A missing file (mid-recreate) is ignored so a transient gap never churns the
connection.
@Sway-Chan Sway-Chan force-pushed the fix/reopen-replaced-db-925 branch from c0c6b43 to 4761346 Compare June 19, 2026 09:31
@Sway-Chan Sway-Chan closed this Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant