Skip to content

fix(mcp): keep ADR reads nonblocking during reindex - #892

Merged
DeusData merged 2 commits into
DeusData:mainfrom
SS-42:fix/adr-cross-process-lock
Jul 31, 2026
Merged

DeusData merged 2 commits into
DeusData:mainfrom
SS-42:fix/adr-cross-process-lock

Conversation

@SS-42

@SS-42 SS-42 commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • keep manage_adr(get|sections) on query-only store access while a same-project reindex holds the mutation lease
  • preserve serialized manage_adr(update|store) writes through the native project-lock manager
  • return legacy-file ADR content immediately; migration attempts persistence only after a nonblocking lease and rechecks the current SQLite generation before writing
  • forward the HTTP server nonblocking mutation callback to its embedded MCP server
  • distinguish a retryable corrupt-store recovery lease conflict from an embedding that lacks a nonblocking callback
  • document that cached ADR reads can temporarily return pre-publication content after an out-of-process reindex

Environment observed

  • macOS arm64
  • multiple independent MCP/CLI processes sharing one CBM_CACHE_DIR

Regression coverage

  • stored ADR get and sections bypass a busy blocking mutation guard
  • update remains serialized through the primary mutation guard
  • a busy legacy migration returns readable content without blocking; a later successful try-acquire persists it
  • corrupt-store ADR reads use the nonblocking path, preserve the live generation when busy, and report a missing try callback as a configuration error
  • HTTP embeds the same nonblocking callback used by its existing mutation guard

Verification

  • make -f Makefile.cbm test-focused TEST_SUITES=mcp_mutation_guard (24 passed)
  • git diff --check
  • bash scripts/lint.sh --ci
  • bash scripts/test.sh (6801 passed, 0 failed, 4 skipped across 120 suites; watchdog and security regressions passed)

@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch 4 times, most recently from 42b1878 to c52e266 Compare July 5, 2026 23:20
@SS-42
SS-42 marked this pull request as ready for review July 6, 2026 00:47
@SS-42
SS-42 requested a review from DeusData as a code owner July 6, 2026 00:47
@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch from c52e266 to ba39ffd Compare July 6, 2026 13:53
@DeusData DeusData added bug Something isn't working stability/performance Server crashes, OOM, hangs, high CPU/memory priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. labels Jul 7, 2026
@DeusData

DeusData commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Thanks for the process-serialization work. Triage: high-priority stability bug.

Review will focus on lock scope and failure behavior: same-project serialization without blocking unrelated projects, no watcher/self-lock deadlocks, safe stale-store reopen behavior, and ADR migration/write ordering across independent MCP/CLI processes.

@DeusData DeusData added this to the 0.9.1-rc milestone Jul 8, 2026
@DeusData

DeusData commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Thanks — this addresses a real gap (independent MCP/CLI processes sharing one CBM_CACHE_DIR had no cross-process serialization), and the lock hygiene is right: 0600 lock files under the cache dir, FD_CLOEXEC, unlock-before-close, per-project scoping so unrelated indexes don't serialize, and project names are sanitized upstream so the lock filename is traversal-safe. The fork-based cross-process tests are a nice touch. Two changes requested before merge:

  1. Vacuous pass in test setup (tests/test_mcp.c, the new lock tests): if (!cbm_mkdtemp(cache)) { PASS(); } turns a setup failure into a green test. Our suite treats that as a broken guard — a test that cannot establish its preconditions has FAILED, not passed (see the FAIL() macro doc in test_framework.h). Please use FAIL("mkdtemp failed") there (and in any sibling occurrences in the new tests).

  2. Windows is a silent no-op — cbm_pipeline_try_lock_project returns true unconditionally under _WIN32, so Windows keeps today's cross-process races. That's a fine incremental scope (no regression), but please make it explicit: a comment at the #ifdef stating the limitation (+ ideally a one-line debug log), so the next Windows-locking pass knows this is intentionally unimplemented rather than accidentally missing. If you're up for it, LockFileEx on the same lock-file path is the natural follow-up — separate PR is fine.

Everything else looks merge-ready: 19/19 checks green, clean mergeable state against current main (impressive, given how much mcp.c/pipeline.c moved this week), scope matches the description exactly, no stray files.

@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch from ba39ffd to 0a8b63e Compare July 9, 2026 18:21
@SS-42

SS-42 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Addressed both requested changes in 0a8b63e: test setup now fails on mkdtemp errors, and the Windows lock limitation is explicit with a debug log. Also tightened cancellation and test cleanup.

@DeusData

Copy link
Copy Markdown
Owner

Thanks @SS-42 — reviewed in depth and the mechanism is sound: POSIX fcntl advisory locks are the right choice (they auto-release on process death, so there are no stale lock files to garbage-collect), the lock filename is path-traversal-safe, and there's no ABBA/self-deadlock. We'd like to take this, with two changes before merge:

  1. Don't let ADR reads block behind a reindex. Right now manage_adr takes the blocking pipeline lock for all modes, so a get/sections read can hang for minutes behind a same-project full reindex in another process. Please give the read paths a non-blocking/try-lock-with-timeout path (serialize writes, but let reads proceed or fail fast rather than hang).
  2. Rebase onto current main. The base has diverged a lot (the cbm_pipeline_lock() sites and handle_manage_adr have moved), so this needs a rebase + a green re-run before we can merge.

The Windows no-op is fine as documented (it's not a regression — there was no cross-process lock there before). Really appreciate this.

winnyschuster pushed a commit to winnyschuster/codebase-memory-mcp that referenced this pull request Jul 16, 2026
… reclaimed

On-disk write connections set no journal_size_limit, so the -wal file is never
physically shrunk: all our checkpoints are PASSIVE (TRUNCATE is deliberately
avoided because its ftruncate(fd,0) can SIGBUS a sibling mmap'd process on
macOS), and PASSIVE never ftruncates. Under checkpoint starvation the WAL then
grows without bound (reported: 115 GB, filled the drive) and even a later
successful checkpoint leaves the file at its peak size (DeusData#1083).

Set journal_size_limit = 256 MiB on write connections so SQLite truncates the
-wal back to that bound whenever a checkpoint can finally reset the log —
reclaiming disk opportunistically during a session, not only on clean close.
256 MiB is far above the healthy WAL (~4 MiB under the default 1000-page
autocheckpoint), so normal indexing never triggers truncate/regrow churn; the
limit only fires after abnormal growth. No TRUNCATE checkpoint is introduced.

Scope: this is the *reclaim* half of DeusData#1083. It does not prevent the peak under
continuous reader contention (a checkpoint can't reset the log while a reader
holds a mark) — the write-amplification driver (concurrent full re-indexes of
one repo) is serialized by the cross-process pipeline lock in DeusData#892.

Test: a file-backed write store now reports journal_size_limit = 256 MiB
(RED on main: -1 / unlimited).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch from 0a8b63e to 918ba1e Compare July 26, 2026 18:43
@SS-42 SS-42 changed the title fix(mcp): serialize ADR access across processes fix(mcp): keep ADR reads nonblocking during reindex Jul 26, 2026
@SS-42

SS-42 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main. get and sections now use read-only access without waiting for a project mutation; legacy migration is nonblocking, while updates remain serialized. Added regression coverage and reran the full suite.

@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch from 918ba1e to df6d61e Compare July 26, 2026 18:45
ycsx pushed a commit to ycsx/codebase-memory-mcp that referenced this pull request Jul 28, 2026
… reclaimed

On-disk write connections set no journal_size_limit, so the -wal file is never
physically shrunk: all our checkpoints are PASSIVE (TRUNCATE is deliberately
avoided because its ftruncate(fd,0) can SIGBUS a sibling mmap'd process on
macOS), and PASSIVE never ftruncates. Under checkpoint starvation the WAL then
grows without bound (reported: 115 GB, filled the drive) and even a later
successful checkpoint leaves the file at its peak size (DeusData#1083).

Set journal_size_limit = 256 MiB on write connections so SQLite truncates the
-wal back to that bound whenever a checkpoint can finally reset the log —
reclaiming disk opportunistically during a session, not only on clean close.
256 MiB is far above the healthy WAL (~4 MiB under the default 1000-page
autocheckpoint), so normal indexing never triggers truncate/regrow churn; the
limit only fires after abnormal growth. No TRUNCATE checkpoint is introduced.

Scope: this is the *reclaim* half of DeusData#1083. It does not prevent the peak under
continuous reader contention (a checkpoint can't reset the log while a reader
holds a mark) — the write-amplification driver (concurrent full re-indexes of
one repo) is serialized by the cross-process pipeline lock in DeusData#892.

Test: a file-backed write store now reports journal_size_limit = 256 MiB
(RED on main: -1 / unlimited).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
@DeusData

Copy link
Copy Markdown
Owner

Reviewed this properly — the concurrency core is sound, and I traced it rather than trusting the description. One small gap stands between it and a merge.

The migration ownership rule is exactly right, and I verified it end to end. Observing have_adr == false on the old handle, then re-resolving the current generation under the try-lease and re-checking before persisting, closes the TOCTOU against both a competing migrator and a publisher that finished before the lease was granted. I looked specifically for a clobber interleaving and there isn't one: the cross-process loser's try simply fails, it returns the legacy content unpersisted, and its next request finds the winner's row and skips the store.

Lease accounting is balanced on every path I traced — try-success plus cancelled, try-success plus resolve-NULL, and the corrupt-recovery try inside resolve_store_internal where mutation_already_held correctly prevents a nested acquire. That matches the try=1/end=1 assertion in the corrupt test.

I also checked the things that would make this dangerous rather than merely clever. Reads now use exactly the store-access class every other query tool has always used, and reindex publication is atomic (temp file plus rename), so a reader either keeps the old inode as a complete snapshot or opens the fully-written new one. No torn read. No lock-ordering inversion — the try path acquires nothing before the lease and never blocks. And reentrancy actually improves: an ADR read from inside an index-holding context can no longer self-deadlock.

The tests are a model for this codebase. Deterministic by construction — a deny-Nth-call probe seam, no sleeps, no timeouts, no probabilistic waits — and genuinely binding: both fail on main without the fix. Proving migration persistence through a subsequent public read, rather than a retained borrowed handle, is a detail I appreciated.

The one ask: the third embedder isn't wired.

cbm_http_server_set_project_mutation_guard (src/ui/http_server.c) forwards begin/end into its embedded MCP server, but there is no try passthrough. So on the UI-embedded server the guard is set while the try hook stays NULL, which your own fail-safe correctly treats as always-busy. Consequences there: legacy ADR migration never persists, and corrupt-store recovery on an ADR read returns "not found" instead of quarantining.

To be clear, this is not a regression — the common case on that surface improves, since UI ADR reads currently fail outright with "blocked by an active index" during a reindex and will now succeed. The gap only affects those two rarer paths. But it leaves the three embedders inconsistent, and it is about three lines: either add a try parameter to the http setter, or call cbm_mcp_server_set_project_mutation_try_guard(srv->mcp, begin) from the host wiring, since the UI begin already has try semantics under the hood.

With that wired, this merges.

Two things worth knowing, neither blocking:

  • A freshness trade you already declare, stated precisely. Main opened a fresh rw handle per ADR get, so reads were always the current generation. Reading the cached query handle means that after an out-of-process reindex publication, ADR reads can serve the pre-publication inode until idle eviction — 60s normally, but indefinitely under continuous access to that project. In-place ADR updates by other processes stay visible (same inode, SQLite change counter), and same-server mutations invalidate the cache, so the stale window covers only publication-replaced content. It is the same staleness class as every other query tool, which is why I read it as the deliberate "stable query snapshot" trade rather than a defect — I have flagged it for the maintainer as an awareness item.
  • A small fragility. In the migration failure path, resolved and store dangle after invalidate_cached_store. I convinced myself they are provably never dereferenced (have_adr stays false, the response uses legacy, and the tail only closes owned_rw which is NULL) — but it is one future edit away from a use-after-free. NULLing both at invalidation would cost nothing.

Minor: on a corrupt store with a busy lease, an ADR read reports "not found / not indexed", which is misleading — "busy, retry" would be truer.

Thank you for rebasing this onto the post-daemon-rework internals; that is why it still applies cleanly after a month, and it made the review much easier than it would otherwise have been.

Signed-off-by: SS-42 <noreply@incogni.to>
@SS-42
SS-42 force-pushed the fix/adr-cross-process-lock branch from df6d61e to fb7a635 Compare July 31, 2026 08:03
@SS-42

SS-42 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and addressed the remaining UI embedding path in fb7a635. The HTTP server now forwards its existing nonblocking mutation callback to embedded MCP. Corrupt-store ADR reads distinguish retryable active lease conflicts from missing nonblocking callback configuration, and legacy migration clears stale local pointers before re-resolving. Updated README and regression coverage.

Verified with make -f Makefile.cbm test-focused TEST_SUITES=mcp_mutation_guard (24 passed), bash scripts/lint.sh --ci, and bash scripts/test.sh (6801 passed, 0 failed, 4 skipped; watchdog and security regressions passed).

@DeusData

Copy link
Copy Markdown
Owner

Checked the delta, and the third embedder is correctly wired. Thank you.

I want to be specific about what I verified, because I nearly filed a false finding against you. The http_server.c change registers the same begin callback as the try-guard:

cbm_mcp_server_set_project_mutation_try_guard(srv->mcp, begin);

Next to main.c and daemon/application.c — where you correctly built genuinely nonblocking variants (main_local_cli_mutation_try_begin routing to cbm_project_lock_try_acquire, and application_session_mutation_try_begin passing wait = false) — that one looked like a blocking function being handed to a nonblocking hook. So I traced the caller before saying anything: daemon/host.c:334 supplies host_ui_mutation_begin, which at :306 already calls cbm_daemon_application_project_mutation_**try**_begin. It is nonblocking by construction, so passing it straight through is right, and adding a second wrapper would only have added indirection. Good call.

The new mcp.h contract reads well too — documenting that a successful try-acquire is released through the primary guard's end hook is exactly the sort of thing that stops the next person from pairing it wrongly, and failing with a configuration error rather than silently falling back to the blocking begin is the correct default.

Where this stands: CI is running (19 checks in flight, none failed). Beyond that, there is one item left that is not about your code at all — a maintainer policy question about accepting a pseudonymous DCO sign-off (SS-42 <noreply@incogni.to>). The dco check itself passes; this is a project-policy call the maintainer owns, and I have put it to him rather than deciding it in review. I mention it plainly so you know exactly what the PR is waiting on and can weigh in if you would like to.

One consequence worth restating for the record, since it changes observable behaviour of a public tool and your PR declares it deliberately: ADR get/sections move from a fresh read-write handle per request to the cached query handle, so after an out-of-process reindex publication an ADR read can serve the pre-publication inode until idle eviction. In-place edits stay visible; only publication-replaced content goes stale. That is the same staleness class as every other query tool, which is why I did not treat it as a defect — but it is a real trade and it should be on the record rather than discovered later.

Thanks for running the full suite and reporting the numbers; 6801 passed with the watchdog and security regressions green is a useful thing to have in the thread.

Signed-off-by: SS-42 <noreply@incogni.to>
@SS-42

SS-42 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Documented the cached-read behavior in 2d0307b. The README now states that manage_adr(get|sections) can serve the pre-publication ADR after an out-of-process store replacement until idle eviction refreshes the query cache, while updates remain serialized.

@DeusData
DeusData merged commit d698db8 into DeusData:main Jul 31, 2026
28 checks passed
@DeusData

Copy link
Copy Markdown
Owner

Merged as d698db8e1. Thank you — and thank you particularly for the last commit.

I had asked that the ADR-read staleness trade be on the record rather than left to be discovered later, and you put it in the README rather than in a thread nobody will find again. That paragraph is exactly right: query modes use the cached query store so they can proceed during a same-project reindex, an out-of-process publication can therefore serve the pre-publication ADR until idle eviction, and updates stay serialized through the mutation guard. That is the honest description of the trade, written where a user will actually meet it.

On the code itself: the third-embedder wiring came through correctly. I checked the one thing that looked wrong — http_server.c passing the same begin callback as the try-guard, next to main.c and daemon/application.c where you built genuinely nonblocking variants — and traced it to daemon/host.c:306, where host_ui_mutation_begin already calls ..._mutation_try_begin. Nonblocking by construction, so passing it through is right and a second wrapper would only have added indirection.

The mcp.h contract reads well too: documenting that a successful try-acquire is released through the primary guard's end hook is the sort of thing that stops the next person pairing it wrongly, and failing with a configuration error rather than silently falling back to the blocking begin is the correct default.

Thanks also for running the full suite and posting the numbers — 6801 passed with the watchdog and security regressions green is a genuinely useful thing to have in the thread rather than a claim of "works for me".

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

Labels

bug Something isn't working priority/high Needs near-term maintainer attention; high-impact bug, regression, safety issue, or release blocker. stability/performance Server crashes, OOM, hangs, high CPU/memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants