Skip to content

Commit 0a8b63e

Browse files
author
SS-42
committed
fix(mcp): serialize ADR access across processes
Use project-scoped cache-local advisory locks so independent MCP and CLI processes sharing CBM_CACHE_DIR cannot rebuild the same project DB concurrently without blocking unrelated project indexes. Serialize manage_adr reads and writes with same-project rebuilds, reopen stale file-backed stores before ADR access, and avoid watcher self-locking when a supervised worker performs the reindex. Cover the cross-process lock behavior with fork-based regression tests. Signed-off-by: SS-42 <noreply@incogni.to>
1 parent a5351d3 commit 0a8b63e

6 files changed

Lines changed: 808 additions & 26 deletions

File tree

src/main.c

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,9 @@ static void request_shutdown(void) {
8686
cbm_pipeline_cancel(p);
8787
}
8888
}
89-
/* Release pipeline lock to prevent stale lock on restart */
90-
cbm_pipeline_unlock();
89+
/* Do not unlock here: cancellation is cooperative, so cbm_pipeline_run()
90+
* may still be writing. The lock owner releases after the run returns; if
91+
* the process dies first, the OS releases the file descriptor lock. */
9192

9293
if (g_watcher) {
9394
cbm_watcher_stop(g_watcher);
@@ -164,32 +165,38 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi
164165
return 0;
165166
}
166167

167-
/* Non-blocking: skip if another pipeline is already running.
168-
* Watcher will retry on next poll cycle (5-60s). */
169-
if (!cbm_pipeline_try_lock()) {
168+
/* Non-blocking: skip if this project is already being rebuilt. Watcher will
169+
* retry on next poll cycle (5-60s). */
170+
if (!cbm_pipeline_try_lock_project(project_name)) {
170171
cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy");
171172
return 0;
172173
}
174+
cbm_pipeline_unlock();
173175

174176
cbm_log_info("watcher.reindex", "project", project_name, "path", root_path);
175177

176178
/* #832: route the re-index through the supervised worker subprocess so this
177179
* long-lived server process hands its RSS back to the OS on every cycle
178180
* instead of ratcheting (mimalloc v3 does not reclaim pages that worker
179181
* threads abandon at exit). The child writes the DB; the parent only needs the
180-
* return code. The pipeline lock (already held) still serialises re-indexes.
182+
* return code. The worker takes the project lock; the parent must not keep
183+
* it held while waiting, otherwise the child blocks on the same file lock.
181184
* Degrade to the in-process pipeline when the supervisor is off (kill switch)
182185
* or the spawn fails. */
183186
if (cbm_index_supervisor_should_wrap()) {
184187
char *resp = cbm_mcp_index_run_supervised_path(root_path);
185188
if (resp) {
186189
free(resp);
187-
cbm_pipeline_unlock();
188190
return 0;
189191
}
190192
/* resp == NULL → spawn-failure degrade → fall through to in-process. */
191193
}
192194

195+
if (!cbm_pipeline_try_lock_project(project_name)) {
196+
cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy");
197+
return 0;
198+
}
199+
193200
cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL);
194201
if (!p) {
195202
cbm_pipeline_unlock();

src/mcp/mcp.c

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,6 +1084,18 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
10841084
return srv->store;
10851085
}
10861086

1087+
static void invalidate_cached_file_store(cbm_mcp_server_t *srv) {
1088+
if (!srv || !srv->store || !cbm_store_db_path(srv->store)) {
1089+
return;
1090+
}
1091+
if (srv->owns_store) {
1092+
cbm_store_close(srv->store);
1093+
srv->store = NULL;
1094+
free(srv->current_project);
1095+
srv->current_project = NULL;
1096+
}
1097+
}
1098+
10871099
/* Forward decl — definition lives below alongside list_projects. */
10881100
static bool is_project_db_file(const char *name, size_t len);
10891101

@@ -2192,7 +2204,10 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) {
21922204
}
21932205

21942206
/* Wait for any in-progress pipeline to finish before deleting */
2195-
cbm_pipeline_lock();
2207+
if (!cbm_pipeline_lock_project(name)) {
2208+
free(name);
2209+
return cbm_mcp_text_result("failed to acquire pipeline lock", true);
2210+
}
21962211

21972212
/* Delete the .db file + WAL/SHM */
21982213
char path[CBM_SZ_1K];
@@ -3811,7 +3826,12 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
38113826
/* Serialize pipeline runs to prevent concurrent writes.
38123827
* Track active pipeline so signal handler and notifications/cancelled
38133828
* can cancel it mid-run. */
3814-
cbm_pipeline_lock();
3829+
if (!cbm_pipeline_lock_project(project_name)) {
3830+
cbm_pipeline_free(p);
3831+
free(project_name);
3832+
free(repo_path);
3833+
return cbm_mcp_text_result("failed to acquire pipeline lock", true);
3834+
}
38153835
srv->active_pipeline = p;
38163836
int rc = cbm_pipeline_run(p);
38173837
srv->active_pipeline = NULL;
@@ -5468,11 +5488,20 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
54685488
/* ADRs are stored in the SQLite store (project_summaries), the SAME
54695489
* backend the UI /api/adr endpoints use — so writes via the MCP tool and
54705490
* the UI are visible to each other (#256). */
5491+
if (!cbm_pipeline_lock_project(project)) {
5492+
free(project);
5493+
free(mode_str);
5494+
free(content);
5495+
return cbm_mcp_text_result("failed to acquire pipeline lock", true);
5496+
}
5497+
invalidate_cached_file_store(srv);
5498+
54715499
cbm_store_t *resolved = resolve_store(srv, project);
54725500
if (!resolved) {
54735501
char *err = build_no_store_error(project);
54745502
char *res = cbm_mcp_text_result(err, true);
54755503
free(err);
5504+
cbm_pipeline_unlock();
54765505
free(project);
54775506
free(mode_str);
54785507
free(content);
@@ -5495,6 +5524,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
54955524
char *err = build_no_store_error(project);
54965525
char *res = cbm_mcp_text_result(err, true);
54975526
free(err);
5527+
cbm_pipeline_unlock();
54985528
free(project);
54995529
free(mode_str);
55005530
free(content);
@@ -5553,6 +5583,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
55535583
if (owned_rw) {
55545584
cbm_store_close(owned_rw);
55555585
}
5586+
cbm_pipeline_unlock();
55565587
free(project);
55575588
free(mode_str);
55585589
free(content);
@@ -5741,7 +5772,11 @@ static void *autoindex_thread(void *arg) {
57415772
}
57425773

57435774
/* Block until any concurrent pipeline finishes */
5744-
cbm_pipeline_lock();
5775+
if (!cbm_pipeline_lock_project(srv->session_project)) {
5776+
cbm_pipeline_free(p);
5777+
cbm_log_warn("autoindex.err", "msg", "pipeline_lock_failed");
5778+
return NULL;
5779+
}
57455780
int rc = cbm_pipeline_run(p);
57465781
cbm_pipeline_unlock();
57475782

src/pipeline/pipeline.c

Lines changed: 138 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,32 +41,165 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P
4141
#include <stdatomic.h>
4242
#include <sys/stat.h>
4343
#include <time.h>
44+
#include <errno.h>
45+
#ifndef _WIN32
46+
#include <fcntl.h>
47+
#include <unistd.h>
48+
#endif
4449

4550
static inline void *intptr_to_ptr(intptr_t v) {
4651
void *p;
4752
memcpy(&p, &v, sizeof(p));
4853
return p;
4954
}
5055

51-
/* ── Global index lock ─────────────────────────────────────────── */
52-
/* Prevents concurrent pipeline runs on the same DB file.
53-
* Atomic spinlock: 0 = free, 1 = locked. */
56+
/* ── Project index lock ────────────────────────────────────────── */
57+
/* Prevents concurrent rebuilds of the same project DB. The atomic lock handles
58+
* threads inside one process; the project-scoped cache-local file lock serializes
59+
* independent MCP/CLI processes that share CBM_CACHE_DIR. */
5460
static atomic_int g_pipeline_busy = 0;
61+
#ifndef _WIN32
62+
static int g_pipeline_lock_fd = -1;
63+
#endif
64+
65+
static bool pipeline_file_lock(const char *project, bool wait) {
66+
#ifdef _WIN32
67+
/* Windows still has only the in-process guard; add a LockFileEx backend
68+
* here before claiming cross-process serialization on that platform. */
69+
cbm_log_debug("pipeline.lock.win32_noop", "project", project ? project : "(global)");
70+
(void)wait;
71+
return true;
72+
#else
73+
const char *cache_dir = cbm_resolve_cache_dir();
74+
if (!cache_dir || !cache_dir[0]) {
75+
cache_dir = cbm_tmpdir();
76+
}
77+
if (!cache_dir || !cache_dir[0]) {
78+
cbm_log_warn("pipeline.lock.err", "phase", "cache_dir");
79+
return false;
80+
}
81+
if (!cbm_mkdir_p(cache_dir, CBM_DIR_PERMS)) {
82+
cbm_log_warn("pipeline.lock.err", "phase", "mkdir", "path", cache_dir);
83+
return false;
84+
}
85+
86+
char lock_path[CBM_SZ_1K];
87+
int n;
88+
if (project && cbm_validate_project_name(project)) {
89+
n = snprintf(lock_path, sizeof(lock_path), "%s/.pipeline.%s.lock", cache_dir, project);
90+
} else {
91+
n = snprintf(lock_path, sizeof(lock_path), "%s/.pipeline.lock", cache_dir);
92+
}
93+
if (n <= 0 || (size_t)n >= sizeof(lock_path)) {
94+
cbm_log_warn("pipeline.lock.err", "phase", "path", "project",
95+
project ? project : "(global)");
96+
return false;
97+
}
98+
99+
int flags = O_RDWR | O_CREAT;
100+
#ifdef O_CLOEXEC
101+
flags |= O_CLOEXEC;
102+
#endif
103+
int fd = open(lock_path, flags, 0600);
104+
#ifdef O_CLOEXEC
105+
if (fd < 0 && errno == EINVAL) {
106+
fd = open(lock_path, O_RDWR | O_CREAT, 0600);
107+
}
108+
#endif
109+
if (fd < 0) {
110+
char errbuf[CBM_SZ_64];
111+
snprintf(errbuf, sizeof(errbuf), "%d", errno);
112+
cbm_log_warn("pipeline.lock.err", "phase", "open", "errno", errbuf);
113+
return false;
114+
}
115+
#ifdef FD_CLOEXEC
116+
int fd_flags = fcntl(fd, F_GETFD);
117+
if (fd_flags >= 0) {
118+
(void)fcntl(fd, F_SETFD, fd_flags | FD_CLOEXEC);
119+
}
120+
#endif
121+
122+
struct flock fl;
123+
memset(&fl, 0, sizeof(fl));
124+
fl.l_type = F_WRLCK;
125+
fl.l_whence = SEEK_SET;
126+
127+
for (;;) {
128+
if (fcntl(fd, wait ? F_SETLKW : F_SETLK, &fl) == 0) {
129+
g_pipeline_lock_fd = fd;
130+
return true;
131+
}
132+
if (wait && errno == EINTR) {
133+
continue;
134+
}
135+
int err = errno;
136+
bool lock_busy = err == EACCES || err == EAGAIN;
137+
#ifdef EWOULDBLOCK
138+
lock_busy = lock_busy || err == EWOULDBLOCK;
139+
#endif
140+
if (!wait && lock_busy) {
141+
close(fd);
142+
return false;
143+
}
144+
char errbuf[CBM_SZ_64];
145+
snprintf(errbuf, sizeof(errbuf), "%d", err);
146+
cbm_log_warn("pipeline.lock.err", "phase", "acquire", "errno", errbuf);
147+
close(fd);
148+
return false;
149+
}
150+
#endif
151+
}
152+
153+
static void pipeline_file_unlock(void) {
154+
#ifndef _WIN32
155+
if (g_pipeline_lock_fd < 0) {
156+
return;
157+
}
158+
struct flock fl;
159+
memset(&fl, 0, sizeof(fl));
160+
fl.l_type = F_UNLCK;
161+
fl.l_whence = SEEK_SET;
162+
(void)fcntl(g_pipeline_lock_fd, F_SETLK, &fl);
163+
close(g_pipeline_lock_fd);
164+
g_pipeline_lock_fd = -1;
165+
#endif
166+
}
167+
168+
bool cbm_pipeline_try_lock_project(const char *project) {
169+
if (atomic_exchange(&g_pipeline_busy, 1) != 0) {
170+
return false;
171+
}
172+
if (!pipeline_file_lock(project, false)) {
173+
atomic_store(&g_pipeline_busy, 0);
174+
return false;
175+
}
176+
return true;
177+
}
55178

56179
bool cbm_pipeline_try_lock(void) {
57-
return atomic_exchange(&g_pipeline_busy, 1) == 0;
180+
return cbm_pipeline_try_lock_project(NULL);
58181
}
59182

60183
#define LOCK_SPIN_NS 100000000 /* 100ms between lock retries */
61184

62-
void cbm_pipeline_lock(void) {
185+
bool cbm_pipeline_lock_project(const char *project) {
63186
while (atomic_exchange(&g_pipeline_busy, 1) != 0) {
64187
struct timespec ts = {0, LOCK_SPIN_NS};
65188
cbm_nanosleep(&ts, NULL);
66189
}
190+
if (!pipeline_file_lock(project, true)) {
191+
atomic_store(&g_pipeline_busy, 0);
192+
return false;
193+
}
194+
return true;
195+
}
196+
197+
bool cbm_pipeline_lock(void) {
198+
return cbm_pipeline_lock_project(NULL);
67199
}
68200

69201
void cbm_pipeline_unlock(void) {
202+
pipeline_file_unlock();
70203
atomic_store(&g_pipeline_busy, 0);
71204
}
72205

src/pipeline/pipeline.h

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,24 @@ void cbm_pipeline_get_file_errors(const cbm_pipeline_t *p, cbm_file_error_t **ou
111111

112112
/* ── Index lock (prevents concurrent pipeline runs on same DB) ──── */
113113

114-
/* Try to acquire the global index lock. Returns true if acquired,
115-
* false if another pipeline is already running (non-blocking).
116-
* Use this in the watcher — skip reindex if busy. */
114+
/* Try to acquire the index lock for a project. Returns true if acquired,
115+
* false if this process is already running any pipeline or another process is
116+
* rebuilding the same project (non-blocking). The cross-process file lock is
117+
* project-scoped; the in-process guard remains process-wide because the
118+
* pipeline uses process-global state. Use this in the watcher — skip reindex if
119+
* busy. */
120+
bool cbm_pipeline_try_lock_project(const char *project);
117121
bool cbm_pipeline_try_lock(void);
118122

119-
/* Acquire the global index lock, blocking until available.
120-
* Use this in MCP handler and autoindex — wait for busy watcher to finish. */
121-
void cbm_pipeline_lock(void);
123+
/* Acquire the index lock for a project, blocking until available.
124+
* Returns false only when the lock backend cannot be opened/acquired. Inside
125+
* one process this still serializes all pipeline runs; across processes it only
126+
* waits for the same project. Use this in MCP handler and autoindex — wait for
127+
* busy watcher to finish. */
128+
bool cbm_pipeline_lock_project(const char *project);
129+
bool cbm_pipeline_lock(void);
122130

123-
/* Release the global index lock. */
131+
/* Release the held index lock. */
124132
void cbm_pipeline_unlock(void);
125133

126134
/* ── FQN helpers (used by passes and external callers) ──────────── */

0 commit comments

Comments
 (0)