Skip to content

Daemon auto-sync keeps writing into the unlinked old codegraph.db after codegraph index — edits lost until a tool call reopens it, and the reopen never catches up (1.5.0, 1.6.0, main) #1902

Description

@shijiew

Summary

While the shared MCP daemon is running, a codegraph index (full rebuild) replaces .codegraph/codegraph.db with a new inode (CodeGraph.recreate()removeDatabaseFiles()DatabaseConnection.initialize()). The daemon keeps its file descriptors on the old, unlinked inode, and its file watcher keeps "auto-syncing" every subsequent edit into that dead inode — successfully, so daemon.log fills with Auto-synced N file(s) while the on-disk index at the path never changes.

The #925 self-heal (ToolHandler.freshen()CodeGraph.reopenIfReplaced()) only runs on the MCP tool-call path (getCodeGraph). The watcher's sync path never checks the inode, and when a tool call finally does trigger the reopen, no catch-up sync runs, so every edit auto-synced between the re-index and that tool call is permanently missing from the live index until something touches those files again or the user runs codegraph sync.

Because codegraph status (1.5.0 / 1.6.0) only hash-checks the files git status nominates (#1829, fixed on main in 4871114 but not yet released), the moment the lost edits are committed status reports ✓ Index is up to date for an index that does not contain them.

Real-world impact: on one of our repos the daemon (started 2026-08-25) sat on a dead inode from a codegraph index run on 2026-09-01 until 2026-09-18 — 17 days and ~350 Auto-synced log lines whose writes went nowhere. codegraph node <new symbol> said "not found", codegraph status said "up to date", daemon.log said "auto-synced". codegraph sync . (1.5 s) fixed it: 4 698 → 5 373 nodes.

Environment

  • codegraph 1.5.0 (npm global) and 1.6.0 (npm install @colbymchenry/codegraph@1.6.0 in a scratch prefix) — identical behaviour on both.
  • Linux 6.17 (aarch64), ext4, node:sqlite backend, WAL journal, default daemon mode (codegraph serve --mcp → proxy → detached daemon).
  • Also verified by reading main @ ba3c21e5: src/sync/watcher.ts and src/mcp/engine.ts contain no inode check; recreate() still unlinks; reopenIfReplaced() still does not sync. The writer.pid lock from fix(mcp): fail fast on a second direct-mode writer per project (#1740) #1744 guards a second direct-mode writer, not the CLI index command, so it does not prevent this.

Minimal repro

#!/usr/bin/env bash
# CG=/path/to/codegraph ./repro.sh   (default: codegraph on PATH)
set -euo pipefail
CG=${CG:-codegraph}
W=$(mktemp -d /tmp/cg-repro-XXXX); P=$W/proj; mkdir -p $P/pkg; cd $P
git init -q -b main; printf 'def alpha(x):\n    return x + 1\n' > pkg/core.py; : > pkg/__init__.py
git -c user.name=r -c user.email=r@x add -A; git -c user.name=r -c user.email=r@x commit -qm seed
$CG init . </dev/null >/dev/null 2>&1
echo "codegraph $($CG --version)"
# 1. start the shared daemon exactly as an MCP client would (one initialize + one tool call, then disconnect)
CG=$CG python3 - "$P" <<'PY'
import json,os,subprocess,sys
p=subprocess.Popen([os.environ["CG"],"serve","--mcp","--path",sys.argv[1]],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,bufsize=1)
def rpc(o): p.stdin.write(json.dumps(o)+"\n"); p.stdin.flush()
rpc({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"r","version":"0"}}}); p.stdout.readline()
rpc({"jsonrpc":"2.0","method":"notifications/initialized"})
rpc({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"codegraph_explore","arguments":{"query":"alpha"}}}); p.stdout.readline()
p.stdin.close(); p.wait(10)
PY
sleep 1; DP=$(python3 -c "import json;print(json.load(open('.codegraph/daemon.pid'))['pid'])"); echo "daemon pid $DP"
echo "db inode before index: $(stat -c %i .codegraph/codegraph.db)"
# 2. full rebuild while the daemon is alive
$CG index . </dev/null >/dev/null 2>&1
echo "db inode after  index: $(stat -c %i .codegraph/codegraph.db)"
echo "daemon fds -> deleted inode: $(ls -l /proc/$DP/fd | grep -c 'codegraph.db.*(deleted)')"
# 3. edit a file: the watcher "auto-syncs" — into the dead inode
printf '\n\ndef beta(y):\n    return alpha(y)\n' >> pkg/core.py; sleep 4
echo "daemon.log: $(grep -c Auto-synced .codegraph/daemon.log) auto-sync line(s); last: $(tail -1 .codegraph/daemon.log)"
echo "on-disk index has beta: $(python3 -c "import sqlite3;c=sqlite3.connect('file:.codegraph/codegraph.db?mode=ro',uri=True);print(c.execute(\"select count(*) from nodes where name='beta'\").fetchone()[0])")"
git -c user.name=r -c user.email=r@x add -A; git -c user.name=r -c user.email=r@x commit -qm beta
echo "codegraph status: $($CG status 2>&1 | grep -i 'up to date\|Pending')"
echo "codegraph node beta: $($CG node beta 2>&1 | head -1)"
$CG sync . </dev/null >/dev/null 2>&1
echo "after 'codegraph sync .': $($CG node beta 2>&1 | head -1)"
kill $DP 2>/dev/null || true

Actual (1.6.0)

codegraph 1.6.0
daemon pid 2786279
db inode before index: 16536545
db inode after  index: 16536551
daemon fds -> deleted inode: 5
daemon.log: 1 auto-sync line(s); last: [2026-09-18T21:21:08.007Z] [CodeGraph MCP] Auto-synced 1 file(s) in 54ms
on-disk index has beta: 0
codegraph status: ✓ Index is up to date
codegraph node beta: Symbol "beta" not found in the codebase
after 'codegraph sync .': **beta** (function)

1.5.0 output is identical apart from the missing log timestamps.

Expected

After Auto-synced 1 file(s), codegraph node beta (a fresh process opening the file at the path) finds beta; or at minimum the daemon notices its handle is dead before writing, reopens, and re-syncs what it missed. status should not claim "up to date" for a file whose content hash differs from the indexed one.

Additional observations from the same session

  • ls -l /proc/<daemon>/fd shows codegraph.db (deleted), -wal (deleted), -shm (deleted) immediately after codegraph index; they stay that way through any number of auto-syncs.
  • The first MCP tool call afterwards logs The index was replaced on disk … reopened the live database in place (twice — once per getCodeGraph hit in one explore), the fds move to the new inode, and the edits synced in between are not in the reopened DB (verified: 3 symbols appended to one file during the window, 0 present after reopen; they reappeared only because a later edit to the same file re-synced it wholesale).
  • Edits made after the reopen land correctly. Edits in the same checkout, new files, and git checkout branch switches are all handled fine when the daemon's inode matches the path — the defect is strictly the re-index-under-live-daemon window plus the missing catch-up.
  • codegraph sync . always repairs it, because sync scans the tree and compares size/mtime/hash against the DB rather than trusting git status.

Likely code path (v1.6.0 tag)

  • src/index.ts CodeGraph.recreate()removeDatabaseFiles(dbPath) unlinks the DB the daemon holds open; nothing signals the daemon.
  • src/mcp/engine.ts startWatching()cg.watch({...})src/index.ts watch() syncFn → this.sync({paths}) writes through this.db with no isReplacedOnDisk() check.
  • src/mcp/tools.ts freshen() is the only caller of reopenIfReplaced(), and it is reached only via getCodeGraph() on a tool call.
  • src/index.ts reopenIfReplaced() swaps the connection and rewires layers but never triggers a sync(); engine.catchUpSync() runs only at open time.

Suggested fix (any of these closes the gap)

  1. In the watcher's syncFn (or at the top of CodeGraph.sync()), call reopenIfReplaced() before writing — one stat(), same check freshen() already does.
  2. When reopenIfReplaced() returns true, schedule a full catch-up sync() (the pre-reopen auto-syncs are gone) and log how many pending files were re-scanned.
  3. Have codegraph index refuse, or stop-and-restart the daemon, when .codegraph/daemon.pid / writer.pid points at a live process — the same way recreate() already turns Windows EBUSY into an instruction.

Related: #925 (the tool-call-path reopen this is a gap in), #1829 (status blind spot that hides the result once committed; fixed on main, unreleased), #1740 / #1744 (writer.pid covers direct-mode writers but not the CLI index path), #1361 (sync silent zeros under lock contention — unrelated here; the daemon's syncs succeed, just into the wrong inode).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions