diff --git a/src/main.c b/src/main.c index d533b224..1addf3b2 100644 --- a/src/main.c +++ b/src/main.c @@ -164,12 +164,13 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi return 0; } - /* Non-blocking: skip if another pipeline is already running. - * Watcher will retry on next poll cycle (5-60s). */ - if (!cbm_pipeline_try_lock()) { + /* Non-blocking: skip if this project is already being rebuilt. Watcher will + * retry on next poll cycle (5-60s). */ + if (!cbm_pipeline_try_lock_project(project_name)) { cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy"); return 0; } + cbm_pipeline_unlock(); cbm_log_info("watcher.reindex", "project", project_name, "path", root_path); @@ -177,19 +178,24 @@ static int watcher_index_fn(const char *project_name, const char *root_path, voi * long-lived server process hands its RSS back to the OS on every cycle * instead of ratcheting (mimalloc v3 does not reclaim pages that worker * threads abandon at exit). The child writes the DB; the parent only needs the - * return code. The pipeline lock (already held) still serialises re-indexes. + * return code. The worker takes the project lock; the parent must not keep + * it held while waiting, otherwise the child blocks on the same file lock. * Degrade to the in-process pipeline when the supervisor is off (kill switch) * or the spawn fails. */ if (cbm_index_supervisor_should_wrap()) { char *resp = cbm_mcp_index_run_supervised_path(root_path); if (resp) { free(resp); - cbm_pipeline_unlock(); return 0; } /* resp == NULL → spawn-failure degrade → fall through to in-process. */ } + if (!cbm_pipeline_try_lock_project(project_name)) { + cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy"); + return 0; + } + cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL); if (!p) { cbm_pipeline_unlock(); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e855b4a1..1291c4db 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1084,6 +1084,18 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { return srv->store; } +static void invalidate_cached_file_store(cbm_mcp_server_t *srv) { + if (!srv || !srv->store || !cbm_store_db_path(srv->store)) { + return; + } + if (srv->owns_store) { + cbm_store_close(srv->store); + srv->store = NULL; + free(srv->current_project); + srv->current_project = NULL; + } +} + /* Forward decl — definition lives below alongside list_projects. */ static bool is_project_db_file(const char *name, size_t len); @@ -2192,7 +2204,10 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { } /* Wait for any in-progress pipeline to finish before deleting */ - cbm_pipeline_lock(); + if (!cbm_pipeline_lock_project(name)) { + free(name); + return cbm_mcp_text_result("failed to acquire pipeline lock", true); + } /* Delete the .db file + WAL/SHM */ char path[CBM_SZ_1K]; @@ -3811,7 +3826,12 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { /* Serialize pipeline runs to prevent concurrent writes. * Track active pipeline so signal handler and notifications/cancelled * can cancel it mid-run. */ - cbm_pipeline_lock(); + if (!cbm_pipeline_lock_project(project_name)) { + cbm_pipeline_free(p); + free(project_name); + free(repo_path); + return cbm_mcp_text_result("failed to acquire pipeline lock", true); + } srv->active_pipeline = p; int rc = cbm_pipeline_run(p); srv->active_pipeline = NULL; @@ -5468,11 +5488,20 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { /* ADRs are stored in the SQLite store (project_summaries), the SAME * backend the UI /api/adr endpoints use — so writes via the MCP tool and * the UI are visible to each other (#256). */ + if (!cbm_pipeline_lock_project(project)) { + free(project); + free(mode_str); + free(content); + return cbm_mcp_text_result("failed to acquire pipeline lock", true); + } + invalidate_cached_file_store(srv); + cbm_store_t *resolved = resolve_store(srv, project); if (!resolved) { char *err = build_no_store_error(project); char *res = cbm_mcp_text_result(err, true); free(err); + cbm_pipeline_unlock(); free(project); free(mode_str); free(content); @@ -5495,6 +5524,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { char *err = build_no_store_error(project); char *res = cbm_mcp_text_result(err, true); free(err); + cbm_pipeline_unlock(); free(project); free(mode_str); free(content); @@ -5553,6 +5583,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { if (owned_rw) { cbm_store_close(owned_rw); } + cbm_pipeline_unlock(); free(project); free(mode_str); free(content); @@ -5741,7 +5772,11 @@ static void *autoindex_thread(void *arg) { } /* Block until any concurrent pipeline finishes */ - cbm_pipeline_lock(); + if (!cbm_pipeline_lock_project(srv->session_project)) { + cbm_pipeline_free(p); + cbm_log_warn("autoindex.err", "msg", "pipeline_lock_failed"); + return NULL; + } int rc = cbm_pipeline_run(p); cbm_pipeline_unlock(); diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e9f81f45..7bad28e6 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -41,6 +41,11 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P #include #include #include +#include +#ifndef _WIN32 +#include +#include +#endif static inline void *intptr_to_ptr(intptr_t v) { void *p; @@ -48,25 +53,151 @@ static inline void *intptr_to_ptr(intptr_t v) { return p; } -/* ── Global index lock ─────────────────────────────────────────── */ -/* Prevents concurrent pipeline runs on the same DB file. - * Atomic spinlock: 0 = free, 1 = locked. */ +/* ── Project index lock ────────────────────────────────────────── */ +/* Prevents concurrent rebuilds of the same project DB. The atomic lock handles + * threads inside one process; the project-scoped cache-local file lock serializes + * independent MCP/CLI processes that share CBM_CACHE_DIR. */ static atomic_int g_pipeline_busy = 0; +#ifndef _WIN32 +static int g_pipeline_lock_fd = -1; +#endif + +static bool pipeline_file_lock(const char *project, bool wait) { +#ifdef _WIN32 + (void)project; + (void)wait; + return true; +#else + const char *cache_dir = cbm_resolve_cache_dir(); + if (!cache_dir || !cache_dir[0]) { + cache_dir = cbm_tmpdir(); + } + if (!cache_dir || !cache_dir[0]) { + cbm_log_warn("pipeline.lock.err", "phase", "cache_dir"); + return false; + } + if (!cbm_mkdir_p(cache_dir, CBM_DIR_PERMS)) { + cbm_log_warn("pipeline.lock.err", "phase", "mkdir", "path", cache_dir); + return false; + } + + char lock_path[CBM_SZ_1K]; + int n; + if (project && cbm_validate_project_name(project)) { + n = snprintf(lock_path, sizeof(lock_path), "%s/.pipeline.%s.lock", cache_dir, project); + } else { + n = snprintf(lock_path, sizeof(lock_path), "%s/.pipeline.lock", cache_dir); + } + if (n <= 0 || (size_t)n >= sizeof(lock_path)) { + cbm_log_warn("pipeline.lock.err", "phase", "path", "project", + project ? project : "(global)"); + return false; + } + + int flags = O_RDWR | O_CREAT; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif + int fd = open(lock_path, flags, 0600); +#ifdef O_CLOEXEC + if (fd < 0 && errno == EINVAL) { + fd = open(lock_path, O_RDWR | O_CREAT, 0600); + } +#endif + if (fd < 0) { + char errbuf[CBM_SZ_64]; + snprintf(errbuf, sizeof(errbuf), "%d", errno); + cbm_log_warn("pipeline.lock.err", "phase", "open", "errno", errbuf); + return false; + } +#ifdef FD_CLOEXEC + int fd_flags = fcntl(fd, F_GETFD); + if (fd_flags >= 0) { + (void)fcntl(fd, F_SETFD, fd_flags | FD_CLOEXEC); + } +#endif + + struct flock fl; + memset(&fl, 0, sizeof(fl)); + fl.l_type = F_WRLCK; + fl.l_whence = SEEK_SET; + + for (;;) { + if (fcntl(fd, wait ? F_SETLKW : F_SETLK, &fl) == 0) { + g_pipeline_lock_fd = fd; + return true; + } + if (wait && errno == EINTR) { + continue; + } + int err = errno; + bool lock_busy = err == EACCES || err == EAGAIN; +#ifdef EWOULDBLOCK + lock_busy = lock_busy || err == EWOULDBLOCK; +#endif + if (!wait && lock_busy) { + close(fd); + return false; + } + char errbuf[CBM_SZ_64]; + snprintf(errbuf, sizeof(errbuf), "%d", err); + cbm_log_warn("pipeline.lock.err", "phase", "acquire", "errno", errbuf); + close(fd); + return false; + } +#endif +} + +static void pipeline_file_unlock(void) { +#ifndef _WIN32 + if (g_pipeline_lock_fd < 0) { + return; + } + struct flock fl; + memset(&fl, 0, sizeof(fl)); + fl.l_type = F_UNLCK; + fl.l_whence = SEEK_SET; + (void)fcntl(g_pipeline_lock_fd, F_SETLK, &fl); + close(g_pipeline_lock_fd); + g_pipeline_lock_fd = -1; +#endif +} + +bool cbm_pipeline_try_lock_project(const char *project) { + if (atomic_exchange(&g_pipeline_busy, 1) != 0) { + return false; + } + if (!pipeline_file_lock(project, false)) { + atomic_store(&g_pipeline_busy, 0); + return false; + } + return true; +} bool cbm_pipeline_try_lock(void) { - return atomic_exchange(&g_pipeline_busy, 1) == 0; + return cbm_pipeline_try_lock_project(NULL); } #define LOCK_SPIN_NS 100000000 /* 100ms between lock retries */ -void cbm_pipeline_lock(void) { +bool cbm_pipeline_lock_project(const char *project) { while (atomic_exchange(&g_pipeline_busy, 1) != 0) { struct timespec ts = {0, LOCK_SPIN_NS}; cbm_nanosleep(&ts, NULL); } + if (!pipeline_file_lock(project, true)) { + atomic_store(&g_pipeline_busy, 0); + return false; + } + return true; +} + +bool cbm_pipeline_lock(void) { + return cbm_pipeline_lock_project(NULL); } void cbm_pipeline_unlock(void) { + pipeline_file_unlock(); atomic_store(&g_pipeline_busy, 0); } diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 535c717f..ded2308b 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -111,16 +111,19 @@ void cbm_pipeline_get_file_errors(const cbm_pipeline_t *p, cbm_file_error_t **ou /* ── Index lock (prevents concurrent pipeline runs on same DB) ──── */ -/* Try to acquire the global index lock. Returns true if acquired, - * false if another pipeline is already running (non-blocking). +/* Try to acquire the index lock for a project. Returns true if acquired, + * false if that project is already being rebuilt (non-blocking). * Use this in the watcher — skip reindex if busy. */ +bool cbm_pipeline_try_lock_project(const char *project); bool cbm_pipeline_try_lock(void); -/* Acquire the global index lock, blocking until available. +/* Acquire the index lock for a project, blocking until available. + * Returns false only when the lock backend cannot be opened/acquired. * Use this in MCP handler and autoindex — wait for busy watcher to finish. */ -void cbm_pipeline_lock(void); +bool cbm_pipeline_lock_project(const char *project); +bool cbm_pipeline_lock(void); -/* Release the global index lock. */ +/* Release the held index lock. */ void cbm_pipeline_unlock(void); /* ── FQN helpers (used by passes and external callers) ──────────── */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index b358cd01..3f46a5f5 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -26,6 +26,7 @@ #define cbm_chdir _chdir #define cbm_getcwd _getcwd #else +#include #include /* waitpid — #845 fork+alarm harness */ #include #define cbm_chdir chdir @@ -2088,6 +2089,102 @@ TEST(tool_manage_adr_unified_backend_issue256) { PASS(); } +TEST(tool_manage_adr_update_waits_for_pipeline_lock) { +#ifdef _WIN32 + SKIP_PLATFORM("fork-based pipeline lock test is POSIX-only"); +#else + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-adr-lock-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "adr-lock-project"; + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/adr-lock-project"), CBM_STORE_OK); + cbm_store_close(setup); + + int start_pipe[2]; + ASSERT_EQ(pipe(start_pipe), 0); + + pid_t pid = fork(); + if (pid < 0) { + close(start_pipe[0]); + close(start_pipe[1]); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + FAIL("fork failed"); + } + + if (pid == 0) { + close(start_pipe[1]); + char token = 0; + ssize_t nread = read(start_pipe[0], &token, 1); + close(start_pipe[0]); + if (nread != 1) { + _exit(3); + } + alarm(5); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + _exit(4); + } + char *resp = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"adr-lock-project\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nLocked ADR write.\\n\"}"); + bool ok = resp && strstr(resp, "updated"); + free(resp); + cbm_mcp_server_free(srv); + _exit(ok ? 0 : 5); + } + + close(start_pipe[0]); + ASSERT_TRUE(cbm_pipeline_lock_project(project)); + ASSERT_EQ(write(start_pipe[1], "x", 1), 1); + close(start_pipe[1]); + + cbm_usleep(100000); + cbm_store_t *pre = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(pre); + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + ASSERT_NEQ(cbm_store_adr_get(pre, project, &adr), CBM_STORE_OK); + cbm_store_close(pre); + + cbm_pipeline_unlock(); + + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + ASSERT(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + + cbm_store_t *post = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(post); + memset(&adr, 0, sizeof(adr)); + ASSERT_EQ(cbm_store_adr_get(post, project, &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_NOT_NULL(strstr(adr.content, "Locked ADR write.")); + cbm_store_adr_free(&adr); + cbm_store_close(post); + + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + cbm_rmdir(cache); + PASS(); +#endif +} + TEST(tool_index_repository_reports_store_backed_adr) { char tmp_dir[256]; snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); @@ -4797,6 +4894,7 @@ SUITE(mcp) { RUN_TEST(tool_manage_adr_no_project); RUN_TEST(tool_manage_adr_get_with_existing_adr); RUN_TEST(tool_manage_adr_unified_backend_issue256); + RUN_TEST(tool_manage_adr_update_waits_for_pipeline_lock); RUN_TEST(tool_index_repository_reports_store_backed_adr); RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr); RUN_TEST(index_supervisor_gate_requires_marked_host_issue845); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ff503e56..315b1eed 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -18,7 +18,12 @@ #include #include #include "foundation/compat_thread.h" +#include #include +#ifndef _WIN32 +#include +#include +#endif #include #include #include "graph_buffer/graph_buffer.h" @@ -29,6 +34,35 @@ static char g_tmpdir[256]; +typedef struct { + char tmpdir[256]; + char old_cache[1024]; + bool had_old_cache; +} pipeline_lock_env_t; + +static bool pipeline_lock_env_setup(pipeline_lock_env_t *env) { + memset(env, 0, sizeof(*env)); + const char *old_cache = getenv("CBM_CACHE_DIR"); + if (old_cache && old_cache[0]) { + env->had_old_cache = true; + snprintf(env->old_cache, sizeof(env->old_cache), "%s", old_cache); + } + snprintf(env->tmpdir, sizeof(env->tmpdir), "/tmp/cbm_pipeline_lock_XXXXXX"); + if (!cbm_mkdtemp(env->tmpdir)) { + return false; + } + return cbm_setenv("CBM_CACHE_DIR", env->tmpdir, 1) == 0; +} + +static void pipeline_lock_env_restore(pipeline_lock_env_t *env) { + if (env->had_old_cache) { + cbm_setenv("CBM_CACHE_DIR", env->old_cache, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + th_rmtree(env->tmpdir); +} + /* Create: * /tmp/cbm_test_XXXXXX/ * main.go (empty) @@ -5903,6 +5937,9 @@ TEST(incremental_kustomize_module_indexed) { /* ── Index lock tests ───────────────────────────────────────────── */ TEST(pipeline_lock_try_acquire) { + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + /* First try-lock should succeed */ ASSERT_TRUE(cbm_pipeline_try_lock()); /* Second try-lock should fail (already held) */ @@ -5911,16 +5948,21 @@ TEST(pipeline_lock_try_acquire) { cbm_pipeline_unlock(); ASSERT_TRUE(cbm_pipeline_try_lock()); cbm_pipeline_unlock(); + pipeline_lock_env_restore(&env); PASS(); } TEST(pipeline_lock_blocking) { + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + /* Lock, then unlock — basic sanity */ - cbm_pipeline_lock(); + ASSERT_TRUE(cbm_pipeline_lock()); cbm_pipeline_unlock(); /* Should be immediately re-acquirable */ - cbm_pipeline_lock(); + ASSERT_TRUE(cbm_pipeline_lock()); cbm_pipeline_unlock(); + pipeline_lock_env_restore(&env); PASS(); } @@ -5941,8 +5983,11 @@ static void *try_lock_thread(void *arg) { } TEST(pipeline_lock_contention) { + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + /* Main thread holds lock, spawned thread should fail try_lock */ - cbm_pipeline_lock(); + ASSERT_TRUE(cbm_pipeline_lock()); atomic_store(&g_thread_acquired, -1); atomic_store(&g_thread_done, 0); @@ -5956,12 +6001,16 @@ TEST(pipeline_lock_contention) { /* Thread should NOT have acquired the lock */ ASSERT_EQ(atomic_load(&g_thread_acquired), 0); cbm_pipeline_unlock(); + pipeline_lock_env_restore(&env); PASS(); } TEST(pipeline_lock_release_allows_contender) { + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + /* Main thread acquires and releases, then spawned thread should succeed */ - cbm_pipeline_lock(); + ASSERT_TRUE(cbm_pipeline_lock()); cbm_pipeline_unlock(); atomic_store(&g_thread_acquired, -1); @@ -5974,7 +6023,221 @@ TEST(pipeline_lock_release_allows_contender) { /* Thread SHOULD have acquired the lock */ ASSERT_EQ(atomic_load(&g_thread_acquired), 1); + pipeline_lock_env_restore(&env); + PASS(); +} + +TEST(pipeline_lock_try_reports_busy_across_processes) { +#ifdef _WIN32 + SKIP_PLATFORM("cross-process fork lock test is POSIX-only"); +#else + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + + int sync_pipe[2]; + ASSERT_EQ(pipe(sync_pipe), 0); + + pid_t pid = fork(); + if (pid < 0) { + close(sync_pipe[0]); + close(sync_pipe[1]); + pipeline_lock_env_restore(&env); + FAIL("fork failed"); + } + + if (pid == 0) { + close(sync_pipe[1]); + char token = 0; + ssize_t nread = read(sync_pipe[0], &token, 1); + close(sync_pipe[0]); + if (nread != 1) { + _exit(3); + } + if (cbm_pipeline_try_lock_project("lock-project")) { + cbm_pipeline_unlock(); + _exit(2); + } + _exit(0); + } + + close(sync_pipe[0]); + ASSERT_TRUE(cbm_pipeline_lock_project("lock-project")); + ASSERT_EQ(write(sync_pipe[1], "x", 1), 1); + close(sync_pipe[1]); + + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + cbm_pipeline_unlock(); + + ASSERT(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + pipeline_lock_env_restore(&env); + PASS(); +#endif +} + +TEST(pipeline_lock_allows_distinct_projects_across_processes) { +#ifdef _WIN32 + SKIP_PLATFORM("cross-process fork lock test is POSIX-only"); +#else + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + + int sync_pipe[2]; + ASSERT_EQ(pipe(sync_pipe), 0); + + pid_t pid = fork(); + if (pid < 0) { + close(sync_pipe[0]); + close(sync_pipe[1]); + pipeline_lock_env_restore(&env); + FAIL("fork failed"); + } + + if (pid == 0) { + close(sync_pipe[1]); + char token = 0; + ssize_t nread = read(sync_pipe[0], &token, 1); + close(sync_pipe[0]); + if (nread != 1) { + _exit(3); + } + if (!cbm_pipeline_try_lock_project("lock-project-b")) { + _exit(2); + } + cbm_pipeline_unlock(); + _exit(0); + } + + close(sync_pipe[0]); + ASSERT_TRUE(cbm_pipeline_lock_project("lock-project-a")); + ASSERT_EQ(write(sync_pipe[1], "x", 1), 1); + close(sync_pipe[1]); + + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + cbm_pipeline_unlock(); + + ASSERT(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + pipeline_lock_env_restore(&env); + PASS(); +#endif +} + +TEST(pipeline_lock_blocking_waits_across_processes) { +#ifdef _WIN32 + SKIP_PLATFORM("cross-process fork lock test is POSIX-only"); +#else + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + + int start_pipe[2]; + int acquired_pipe[2]; + ASSERT_EQ(pipe(start_pipe), 0); + ASSERT_EQ(pipe(acquired_pipe), 0); + + int flags = fcntl(acquired_pipe[0], F_GETFL, 0); + ASSERT(flags >= 0); + ASSERT_EQ(fcntl(acquired_pipe[0], F_SETFL, flags | O_NONBLOCK), 0); + + pid_t pid = fork(); + if (pid < 0) { + close(start_pipe[0]); + close(start_pipe[1]); + close(acquired_pipe[0]); + close(acquired_pipe[1]); + pipeline_lock_env_restore(&env); + FAIL("fork failed"); + } + + if (pid == 0) { + close(start_pipe[1]); + close(acquired_pipe[0]); + char token = 0; + ssize_t nread = read(start_pipe[0], &token, 1); + close(start_pipe[0]); + if (nread != 1) { + _exit(3); + } + alarm(3); + if (!cbm_pipeline_lock_project("lock-project")) { + _exit(5); + } + ssize_t nwritten = write(acquired_pipe[1], "y", 1); + cbm_pipeline_unlock(); + close(acquired_pipe[1]); + _exit(nwritten == 1 ? 0 : 4); + } + + close(start_pipe[0]); + close(acquired_pipe[1]); + ASSERT_TRUE(cbm_pipeline_lock_project("lock-project")); + ASSERT_EQ(write(start_pipe[1], "x", 1), 1); + close(start_pipe[1]); + + cbm_usleep(100000); + char token = 0; + ssize_t early = read(acquired_pipe[0], &token, 1); + ASSERT_EQ(early, -1); + ASSERT(errno == EAGAIN || errno == EWOULDBLOCK); + + cbm_pipeline_unlock(); + + int current_flags = fcntl(acquired_pipe[0], F_GETFL, 0); + ASSERT(current_flags >= 0); + ASSERT_EQ(fcntl(acquired_pipe[0], F_SETFL, current_flags & ~O_NONBLOCK), 0); + ssize_t final_read = read(acquired_pipe[0], &token, 1); + close(acquired_pipe[0]); + ASSERT_EQ(final_read, 1); + ASSERT_EQ(token, 'y'); + + int status = 0; + ASSERT_EQ(waitpid(pid, &status, 0), pid); + ASSERT(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + pipeline_lock_env_restore(&env); + PASS(); +#endif +} + +TEST(pipeline_lock_failure_returns_and_releases_process_lock) { +#ifdef _WIN32 + SKIP_PLATFORM("pipeline lock backend failure test requires POSIX file locks"); +#else + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_pipeline_lock_bad_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + PASS(); + } + char bad_cache[512]; + snprintf(bad_cache, sizeof(bad_cache), "%s/not-a-dir", tmpdir); + FILE *fp = fopen(bad_cache, "wb"); + ASSERT_NOT_NULL(fp); + fclose(fp); + + const char *old_cache = getenv("CBM_CACHE_DIR"); + char *old_copy = old_cache ? strdup(old_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", bad_cache, 1); + ASSERT_FALSE(cbm_pipeline_lock()); + + if (old_copy) { + cbm_setenv("CBM_CACHE_DIR", old_copy, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + free(old_copy); + + pipeline_lock_env_t env; + ASSERT_TRUE(pipeline_lock_env_setup(&env)); + ASSERT_TRUE(cbm_pipeline_try_lock()); + cbm_pipeline_unlock(); + pipeline_lock_env_restore(&env); + + remove(bad_cache); + cbm_rmdir(tmpdir); PASS(); +#endif } /* ── Resource management & internal helper tests ─────────────────── */ @@ -6547,6 +6810,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_lock_blocking); RUN_TEST(pipeline_lock_contention); RUN_TEST(pipeline_lock_release_allows_contender); + RUN_TEST(pipeline_lock_try_reports_busy_across_processes); + RUN_TEST(pipeline_lock_allows_distinct_projects_across_processes); + RUN_TEST(pipeline_lock_blocking_waits_across_processes); + RUN_TEST(pipeline_lock_failure_returns_and_releases_process_lock); /* Lifecycle */ RUN_TEST(pipeline_create_free); RUN_TEST(pipeline_null_repo);