mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 08:13:37 +08:00
Merge pull request #2112 from DeusData/fix/decision-a-memory-budget-fail-whole
fix(pipeline,mcp,daemon): fail an over-budget index attempt whole with a named reason (#1997, #832)
This commit is contained in:
+54
-13
@@ -181,7 +181,7 @@ struct cbm_daemon_application {
|
||||
cbm_daemon_application_update_ops_t update_ops;
|
||||
cbm_project_lock_manager_t *project_locks;
|
||||
size_t physical_job_limit;
|
||||
size_t worker_memory_budget_bytes;
|
||||
size_t aggregate_memory_budget_bytes;
|
||||
size_t active_mutations;
|
||||
size_t update_owners;
|
||||
cbm_daemon_application_update_worker_t update_worker;
|
||||
@@ -693,6 +693,8 @@ static bool application_truncate_file(const char *path) {
|
||||
}
|
||||
|
||||
static bool application_job_cancel_requested(cbm_daemon_application_job_t *job);
|
||||
static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *application,
|
||||
size_t *active_jobs_out);
|
||||
|
||||
static char **application_read_suspects(cbm_daemon_application_job_t *job, const char *path,
|
||||
int *count_out, bool *cancelled_out) {
|
||||
@@ -1088,11 +1090,33 @@ static application_attempt_status_t application_job_run_attempt(cbm_daemon_appli
|
||||
return APPLICATION_ATTEMPT_CANCELLED;
|
||||
}
|
||||
|
||||
/* Decision 2 (#1997 #832): the slice is decided at spawn time from the jobs
|
||||
* active right now (this job included), so a lone worker may use the whole
|
||||
* aggregate and concurrent jobs split it. The divisor is logged so a
|
||||
* fail-whole verdict can be read against the budget the worker really had. */
|
||||
size_t active_jobs = 0;
|
||||
cbm_mutex_lock(&application->mutex);
|
||||
size_t memory_budget_bytes = application_worker_memory_slice_locked(application, &active_jobs);
|
||||
size_t aggregate_memory_budget_bytes = application->aggregate_memory_budget_bytes;
|
||||
cbm_mutex_unlock(&application->mutex);
|
||||
if (memory_budget_bytes > 0) {
|
||||
char active_text[32];
|
||||
char aggregate_text[32];
|
||||
char slice_text[32];
|
||||
(void)snprintf(active_text, sizeof(active_text), "%zu", active_jobs);
|
||||
(void)snprintf(aggregate_text, sizeof(aggregate_text), "%zu",
|
||||
aggregate_memory_budget_bytes / (1024U * 1024U));
|
||||
(void)snprintf(slice_text, sizeof(slice_text), "%zu",
|
||||
memory_budget_bytes / (1024U * 1024U));
|
||||
cbm_log_info("daemon.index.worker_budget", "project", job->project_key, "active_jobs",
|
||||
active_text, "aggregate_mb", aggregate_text, "slice_mb", slice_text);
|
||||
}
|
||||
|
||||
cbm_daemon_application_worker_t worker = NULL;
|
||||
application_tmp_lock();
|
||||
int start_result = application->worker_ops.start(
|
||||
application->worker_ops.context, job->args_json, application->worker_memory_budget_bytes,
|
||||
marker_path, quarantine_path, &worker);
|
||||
int start_result =
|
||||
application->worker_ops.start(application->worker_ops.context, job->args_json,
|
||||
memory_budget_bytes, marker_path, quarantine_path, &worker);
|
||||
application_tmp_unlock();
|
||||
if (start_result != 0 || !worker) {
|
||||
return application_job_cancel_requested(job) ? APPLICATION_ATTEMPT_CANCELLED
|
||||
@@ -1564,6 +1588,22 @@ static size_t application_active_job_count_locked(cbm_daemon_application_t *appl
|
||||
return count;
|
||||
}
|
||||
|
||||
/* Decision 2 (#1997 #832): a worker's memory slice is the aggregate budget
|
||||
* divided by the jobs active at spawn time. The job being spawned is already
|
||||
* in the table, so the divisor never drops below one; a zero aggregate means
|
||||
* "no cap" and the worker falls back to its own RAM-fraction budget. */
|
||||
static size_t application_worker_memory_slice_locked(cbm_daemon_application_t *application,
|
||||
size_t *active_jobs_out) {
|
||||
size_t active = application_active_job_count_locked(application);
|
||||
if (active == 0) {
|
||||
active = 1;
|
||||
}
|
||||
if (active_jobs_out) {
|
||||
*active_jobs_out = active;
|
||||
}
|
||||
return application->aggregate_memory_budget_bytes / active;
|
||||
}
|
||||
|
||||
/* Compare the effective index request, not its JSON spelling. yyjson's deep
|
||||
* equality treats object member order as insignificant, while the small
|
||||
* normalization below removes values that the index handler interprets as
|
||||
@@ -2948,18 +2988,19 @@ cbm_daemon_application_t *cbm_daemon_application_new(
|
||||
application->ui_readiness_secret_set = true;
|
||||
}
|
||||
}
|
||||
/* Equal fixed slices keep admission deterministic: starting fewer jobs does
|
||||
* not let an early worker claim memory reserved for later concurrent jobs.
|
||||
* The absurd sub-byte-per-slot case is made safe by reducing effective
|
||||
* capacity before division; normal daemon budgets are many orders larger. */
|
||||
/* The per-worker slice is decided at spawn time (see
|
||||
* application_worker_memory_slice_locked): the aggregate divided by the
|
||||
* jobs active then, so a lone worker may use the whole aggregate and
|
||||
* concurrent jobs split it (decision 2, #1997 #832) — the former fixed
|
||||
* aggregate/limit slice starved a lone job on hosts like #1864. The absurd
|
||||
* sub-byte-per-slot case is still made safe by reducing effective capacity
|
||||
* so every admitted job can receive at least one byte; normal daemon
|
||||
* budgets are many orders larger. */
|
||||
if (aggregate_memory_budget_bytes > 0 &&
|
||||
application->physical_job_limit > aggregate_memory_budget_bytes) {
|
||||
application->physical_job_limit = aggregate_memory_budget_bytes;
|
||||
}
|
||||
if (aggregate_memory_budget_bytes > 0 && application->physical_job_limit > 0) {
|
||||
application->worker_memory_budget_bytes =
|
||||
aggregate_memory_budget_bytes / application->physical_job_limit;
|
||||
}
|
||||
application->aggregate_memory_budget_bytes = aggregate_memory_budget_bytes;
|
||||
if (!application->worker_ops.start) {
|
||||
application->worker_ops = (cbm_daemon_application_worker_ops_t){
|
||||
.context = NULL,
|
||||
@@ -3558,7 +3599,7 @@ size_t cbm_daemon_application_worker_memory_budget_bytes(cbm_daemon_application_
|
||||
return 0;
|
||||
}
|
||||
cbm_mutex_lock(&application->mutex);
|
||||
size_t budget = application->worker_memory_budget_bytes;
|
||||
size_t budget = application_worker_memory_slice_locked(application, NULL);
|
||||
cbm_mutex_unlock(&application->mutex);
|
||||
return budget;
|
||||
}
|
||||
|
||||
@@ -11209,6 +11209,49 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
|
||||
if (cbm_pipeline_had_format_migration(p)) {
|
||||
yyjson_mut_obj_add_bool(doc, root, "format_migration", true);
|
||||
}
|
||||
} else if (rc == CBM_PIPELINE_ABORT_OVER_BUDGET) {
|
||||
/* Decision A (#1997 #832): resident memory stayed above the budget
|
||||
* after back-pressure and one confirmation cycle, so the run stopped
|
||||
* before publication. Name the cause, the numbers and the fact that
|
||||
* the previous index still serves — the generic "check repo_path"
|
||||
* hint below sent people debugging a path that was fine. */
|
||||
int budget_mb = (int)(cbm_mem_budget() / (1024 * 1024));
|
||||
int peak_rss_mb = (int)(cbm_mem_peak_rss() / (1024 * 1024));
|
||||
char budget_text[CBM_SZ_32];
|
||||
char peak_text[CBM_SZ_32];
|
||||
(void)snprintf(budget_text, sizeof(budget_text), "%d", budget_mb);
|
||||
(void)snprintf(peak_text, sizeof(peak_text), "%d", peak_rss_mb);
|
||||
cbm_log_error("index.abort", "reason", "over_memory_budget", "project", project_name,
|
||||
"budget_mb", budget_text, "peak_rss_mb", peak_text);
|
||||
yyjson_mut_obj_add_str(doc, root, "status", "error");
|
||||
yyjson_mut_obj_add_str(doc, root, "reason", "over_memory_budget");
|
||||
yyjson_mut_obj_add_str(doc, root, "previous_index", "preserved");
|
||||
yyjson_mut_obj_add_int(doc, root, "budget_mb", budget_mb);
|
||||
yyjson_mut_obj_add_int(doc, root, "peak_rss_mb", peak_rss_mb);
|
||||
yyjson_mut_obj_add_str(doc, root, "hint",
|
||||
"Indexing stopped: resident memory stayed above the budget after "
|
||||
"backpressure; no partial graph was published and the previous "
|
||||
"index still serves. Raise CBM_MEM_BUDGET_MB, lower CBM_WORKERS, "
|
||||
"or exclude large subtrees.");
|
||||
} else if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB) {
|
||||
/* The truthful abort message (#2020): the old generic "check repo_path"
|
||||
* hint sent people debugging a path that was fine, when the run
|
||||
* aborted pre-publication (semantic inputs changed mid-run, or a
|
||||
* discovery/manifest phase failed transiently) and the previous index
|
||||
* is intact. A 99-test cascade on the Windows leg traced back to
|
||||
* exactly this — the response said error, but not which kind. */
|
||||
yyjson_mut_obj_add_str(doc, root, "status", "aborted_previous_preserved");
|
||||
yyjson_mut_obj_add_str(doc, root, "hint",
|
||||
"Indexing aborted before publication; the previous index is "
|
||||
"intact and still serving. Causes: files changed while the run "
|
||||
"was in flight, or a discovery/manifest phase failed "
|
||||
"transiently. Retry; if it repeats, check the run log.");
|
||||
} else if (rc == CBM_PIPELINE_PERSIST_FAILED) {
|
||||
yyjson_mut_obj_add_str(doc, root, "status", "persist_failed");
|
||||
yyjson_mut_obj_add_str(doc, root, "hint",
|
||||
"The validated staging database could not be published. Check "
|
||||
"free disk space and permissions on the cache directory; the "
|
||||
"previous index may have been rolled back.");
|
||||
} else {
|
||||
yyjson_mut_obj_add_str(doc, root, "status", "error");
|
||||
yyjson_mut_obj_add_str(doc, root, "hint",
|
||||
|
||||
@@ -30,7 +30,9 @@ enum {
|
||||
/* Extraction memory back-pressure: when the process is over its RSS budget,
|
||||
* a worker reclaims + naps before pulling another file so peers can finish
|
||||
* and return pages. Bounded spins avoid deadlock when the resident graph
|
||||
* itself is near budget (then proceed with a soft overshoot). */
|
||||
* itself is near budget: then ONE confirmation cycle decides — drained
|
||||
* means continue, still over means the attempt fails whole (decision A,
|
||||
* #1997 #832) instead of the former open-ended soft overshoot. */
|
||||
PP_BACKPRESSURE_MAX_SPINS = 40,
|
||||
PP_BACKPRESSURE_NAP_NS = 3000000, /* 3 ms */
|
||||
};
|
||||
@@ -667,6 +669,11 @@ typedef struct {
|
||||
* While set, pulls skip the nap (the designed soft overshoot); the cheap
|
||||
* over-budget probe re-arms the gate once RSS drains under budget. */
|
||||
_Atomic int bp_futile;
|
||||
/* Decision A (#1997 #832): set once futility was CONFIRMED by a second
|
||||
* full cycle that still ended over budget. Every pull loop breaks on it
|
||||
* and extract returns CBM_PIPELINE_ABORT_OVER_BUDGET. Deliberately not
|
||||
* the shared cancel token — that one belongs to client cancellation. */
|
||||
_Atomic int over_budget_abort;
|
||||
|
||||
const CBMMacroTable *macro_table; /* ObjectScript $$$macros (NULL if none) */
|
||||
const CBMReturnTypeTable *return_type_table; /* ObjectScript return types (NULL if none) */
|
||||
@@ -722,6 +729,39 @@ static void log_extract_done(int pos, uint64_t ms, int defs, const char *path) {
|
||||
}
|
||||
}
|
||||
|
||||
/* One back-pressure cycle: reclaim this thread's freed pages, then nap in
|
||||
* bounded 3 ms steps while the process stays over budget. Counted for test
|
||||
* observability. Returns true when the FULL cycle elapsed still over budget —
|
||||
* the resident floor, not in-flight transients, holds the memory. */
|
||||
static bool pp_backpressure_cycle_still_over(extract_ctx_t *ec) {
|
||||
cbm_mem_collect();
|
||||
atomic_fetch_add_explicit(&g_bp_nap_cycles, SKIP_ONE, memory_order_relaxed);
|
||||
int bp = 0;
|
||||
for (; bp < PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget() &&
|
||||
!atomic_load_explicit(ec->cancelled, memory_order_relaxed) &&
|
||||
!atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed);
|
||||
bp++) {
|
||||
struct timespec nap = {0, PP_BACKPRESSURE_NAP_NS};
|
||||
cbm_nanosleep(&nap, NULL);
|
||||
}
|
||||
return bp == PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget();
|
||||
}
|
||||
|
||||
/* Decision A (#1997 #832): back-pressure was futile and the confirmation
|
||||
* cycle still ended over budget. The budget stops being advisory here: flag
|
||||
* the attempt so every worker stops pulling and extract returns
|
||||
* CBM_PIPELINE_ABORT_OVER_BUDGET. Cooperative on purpose — the failure must
|
||||
* travel as a clean-exit worker response, never a kill, or the supervisor
|
||||
* would quarantine innocent files. */
|
||||
static void pp_fail_whole_over_budget(extract_ctx_t *ec) {
|
||||
if (atomic_exchange_explicit(&ec->over_budget_abort, 1, memory_order_relaxed) == 0) {
|
||||
cbm_log_error("mem.budget.exceeded", "rss_mb",
|
||||
itoa_log((int)(cbm_mem_rss() / (1024 * 1024))), "budget_mb",
|
||||
itoa_log((int)(cbm_mem_budget() / (1024 * 1024))), "phase",
|
||||
"parallel_extract", "action", "fail_whole");
|
||||
}
|
||||
}
|
||||
|
||||
static void extract_worker(int worker_id, void *ctx_ptr) {
|
||||
extract_ctx_t *ec = ctx_ptr;
|
||||
extract_worker_state_t *ws = &ec->workers[worker_id];
|
||||
@@ -739,7 +779,8 @@ static void extract_worker(int worker_id, void *ctx_ptr) {
|
||||
break;
|
||||
}
|
||||
cbm_scale_tick(&ec->scale, sort_pos);
|
||||
if (atomic_load_explicit(ec->cancelled, memory_order_relaxed)) {
|
||||
if (atomic_load_explicit(ec->cancelled, memory_order_relaxed) ||
|
||||
atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -750,39 +791,41 @@ static void extract_worker(int worker_id, void *ctx_ptr) {
|
||||
* near the budget instead of letting all workers parse their biggest
|
||||
* files at once. Self-disabling when the budget is unset (tests) or RSS
|
||||
* is under budget; bounded spins avoid deadlock when the resident graph
|
||||
* is itself near budget (then proceed with a soft overshoot).
|
||||
* is itself near budget.
|
||||
*
|
||||
* Futility latch: when a FULL nap cycle ends still over budget, the
|
||||
* resident floor — not transients — holds the memory; napping again on
|
||||
* the next pull cannot reclaim it and only idles workers (linux kernel:
|
||||
* one full cycle per pull ≈ 390 s at 79% avg CPU). Latch bp_futile and
|
||||
* proceed with the soft overshoot; the over-budget probe below re-arms
|
||||
* the gate as soon as RSS drains under budget. */
|
||||
* one full cycle per pull ≈ 390 s at 79% avg CPU). The worker that
|
||||
* latches bp_futile (0→1) runs ONE confirmation cycle while its peers
|
||||
* skip the nap. A confirmation that ends under budget re-arms the gate
|
||||
* (RSS drained after all); one that still ends over budget fails the
|
||||
* attempt whole (decision A, #1997 #832): every worker stops pulling,
|
||||
* extract returns CBM_PIPELINE_ABORT_OVER_BUDGET, nothing is published
|
||||
* and the previously serving index keeps answering. */
|
||||
if (cbm_mem_budget() > 0) {
|
||||
bool over = cbm_mem_over_budget();
|
||||
bool futile = atomic_load_explicit(&ec->bp_futile, memory_order_relaxed) != 0;
|
||||
if (over && !futile) {
|
||||
cbm_mem_collect();
|
||||
atomic_fetch_add_explicit(&g_bp_nap_cycles, SKIP_ONE, memory_order_relaxed);
|
||||
int bp = 0;
|
||||
for (; bp < PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget() &&
|
||||
!atomic_load_explicit(ec->cancelled, memory_order_relaxed);
|
||||
bp++) {
|
||||
struct timespec nap = {0, PP_BACKPRESSURE_NAP_NS};
|
||||
cbm_nanosleep(&nap, NULL);
|
||||
}
|
||||
if (bp == PP_BACKPRESSURE_MAX_SPINS && cbm_mem_over_budget()) {
|
||||
/* Log only the 0→1 transition: all workers race into the
|
||||
* gate before anyone latches, so a plain store would WARN
|
||||
* once per worker (12 lines per latch event). */
|
||||
if (atomic_exchange_explicit(&ec->bp_futile, 1, memory_order_relaxed) == 0) {
|
||||
cbm_log_warn("mem.backpressure.futile", "action", "soft_overshoot");
|
||||
/* Act only on the 0→1 transition: all workers race into the
|
||||
* gate before anyone latches, so a plain store would confirm
|
||||
* (and WARN) once per worker. */
|
||||
if (pp_backpressure_cycle_still_over(ec) &&
|
||||
atomic_exchange_explicit(&ec->bp_futile, 1, memory_order_relaxed) == 0) {
|
||||
cbm_log_warn("mem.backpressure.futile", "action", "confirm");
|
||||
if (pp_backpressure_cycle_still_over(ec)) {
|
||||
pp_fail_whole_over_budget(ec);
|
||||
} else {
|
||||
atomic_store_explicit(&ec->bp_futile, 0, memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
} else if (!over && futile) {
|
||||
atomic_store_explicit(&ec->bp_futile, 0, memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
if (atomic_load_explicit(&ec->over_budget_abort, memory_order_relaxed)) {
|
||||
break;
|
||||
}
|
||||
|
||||
int file_idx = ec->sorted[sort_pos].idx;
|
||||
const cbm_file_info_t *fi = &ec->files[file_idx];
|
||||
@@ -1129,6 +1172,7 @@ int cbm_parallel_extract_ex(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
|
||||
atomic_init(&ec.retain_cap_warned, 0);
|
||||
atomic_init(&ec.oversized_warned, 0);
|
||||
atomic_init(&ec.bp_futile, 0);
|
||||
atomic_init(&ec.over_budget_abort, 0);
|
||||
|
||||
/* Sub-phase: Dispatch workers (parse + extract per file, PARALLEL) */
|
||||
CBM_PROF_START(t_dispatch);
|
||||
@@ -1176,6 +1220,12 @@ int cbm_parallel_extract_ex(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
|
||||
free(sorted);
|
||||
cbm_macro_table_free(pp_macro_table); /* ObjectScript macro table (NULL-safe) */
|
||||
|
||||
/* The over-budget verdict outranks the cancel sentinel: a caller must be
|
||||
* able to name the cause, and the orchestrator discards the staging DB on
|
||||
* every non-zero code alike (the live generation is never touched). */
|
||||
if (atomic_load(&ec.over_budget_abort)) {
|
||||
return CBM_PIPELINE_ABORT_OVER_BUDGET;
|
||||
}
|
||||
if (atomic_load(ctx->cancelled)) {
|
||||
return CBM_NOT_FOUND;
|
||||
}
|
||||
|
||||
@@ -63,9 +63,14 @@ void cbm_pipeline_free(cbm_pipeline_t *p);
|
||||
* need to know whether the PREVIOUS generation survived can distinguish the
|
||||
* failures by value: the run publishes by renaming a fully validated staging
|
||||
* database over the destination, so every abort before that rename leaves the
|
||||
* existing database in place. Those codes (CBM_PIPELINE_ABORT_PRESERVE_DB and
|
||||
* CBM_PIPELINE_PERSIST_FAILED) are defined in pipeline_internal.h alongside the
|
||||
* stages that raise them. */
|
||||
* existing database in place. */
|
||||
#define CBM_PIPELINE_ABORT_PRESERVE_DB (-2) /* aborted pre-publication; previous DB intact */
|
||||
#define CBM_PIPELINE_PERSIST_FAILED (-4) /* staging/rollback failure during publication */
|
||||
/* Resident memory stayed above the budget after back-pressure and one
|
||||
* confirmation cycle: the run stops before publication, no partial graph is
|
||||
* written, the previous DB is intact (#1997 #832). Distinct from the cancel
|
||||
* sentinel so callers can name the cause instead of "pipeline failed". */
|
||||
#define CBM_PIPELINE_ABORT_OVER_BUDGET (-5)
|
||||
int cbm_pipeline_run(cbm_pipeline_t *p);
|
||||
|
||||
/* Request cancellation of a running pipeline (thread-safe). */
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
/* Route node QN buffer size (must fit __route__METHOD__/full/url/path) */
|
||||
#define CBM_ROUTE_QN_SIZE 768
|
||||
|
||||
/* Incremental integrity failure: abort the run and preserve the existing DB.
|
||||
* Distinct from CBM_NOT_FOUND, which the orchestrator uses as the normal
|
||||
/* CBM_PIPELINE_ABORT_PRESERVE_DB / CBM_PIPELINE_PERSIST_FAILED moved to
|
||||
* pipeline.h — callers legitimately distinguish them (the header's contract
|
||||
* always said so). FORCE_FULL_REINDEX never escapes the orchestrator and
|
||||
* stays internal. All three are distinct from CBM_NOT_FOUND, the normal
|
||||
* "no incremental route; continue with a full index" sentinel. */
|
||||
#define CBM_PIPELINE_ABORT_PRESERVE_DB (-2)
|
||||
#define CBM_PIPELINE_FORCE_FULL_REINDEX (-3)
|
||||
#define CBM_PIPELINE_PERSIST_FAILED (-4)
|
||||
|
||||
/* Canonicalize route-path parameter placeholders (":id", "{id}", "<id>",
|
||||
* "${...}") to a single "{}" token so that client call sites and server
|
||||
|
||||
@@ -5245,12 +5245,157 @@ TEST(daemon_application_default_limit_admits_four_and_rejects_fifth) {
|
||||
ASSERT_EQ(atomic_load(&fake.starts), DEFAULT_CAP_RUNNING);
|
||||
ASSERT_EQ(atomic_load(&fake.cancels), DEFAULT_CAP_RUNNING);
|
||||
ASSERT_EQ(atomic_load(&fake.destroys), DEFAULT_CAP_RUNNING);
|
||||
size_t assigned_budget = 0;
|
||||
/* Decision 2 (#1997 #832): each worker's slice is the aggregate divided by
|
||||
* the jobs active when IT was spawned. Four requests race through
|
||||
* admission, so which divisor each saw is scheduling-dependent — but every
|
||||
* slice is one of aggregate/1..4 and never exceeds the aggregate. */
|
||||
for (int i = 0; i < DEFAULT_CAP_RUNNING; i++) {
|
||||
ASSERT_EQ(fake.memory_budgets[i], aggregate_budget / DEFAULT_CAP_RUNNING);
|
||||
assigned_budget += fake.memory_budgets[i];
|
||||
bool valid_slice = false;
|
||||
for (size_t divisor = 1; divisor <= DEFAULT_CAP_RUNNING; divisor++) {
|
||||
valid_slice = valid_slice || fake.memory_budgets[i] == aggregate_budget / divisor;
|
||||
}
|
||||
ASSERT_TRUE(valid_slice);
|
||||
ASSERT_TRUE(fake.memory_budgets[i] <= aggregate_budget);
|
||||
}
|
||||
ASSERT_TRUE(assigned_budget <= aggregate_budget);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Decision 2 (#1997 #832): the worker slice is aggregate / jobs ACTIVE at
|
||||
* spawn time. Sequenced admission makes the divisor deterministic: the first
|
||||
* job spawns alone and receives the whole aggregate; the second spawns while
|
||||
* the first still runs and receives half. */
|
||||
TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs) {
|
||||
const size_t aggregate_budget = 4099;
|
||||
app_fake_worker_context_t fake;
|
||||
app_fake_worker_context_init(&fake);
|
||||
cbm_daemon_application_worker_ops_t worker_ops = {
|
||||
.context = &fake,
|
||||
.start = app_fake_worker_start,
|
||||
.poll = app_fake_worker_poll,
|
||||
.cancel = app_fake_worker_cancel,
|
||||
.log_path = app_fake_worker_log_path,
|
||||
.destroy = app_fake_worker_destroy,
|
||||
};
|
||||
cbm_daemon_application_config_t config = {
|
||||
.worker_ops = &worker_ops,
|
||||
.aggregate_memory_budget_bytes = aggregate_budget,
|
||||
};
|
||||
cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
|
||||
char roots[2][APP_TEST_PATH_CAP];
|
||||
bool roots_ok = true;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
(void)snprintf(roots[i], sizeof(roots[i]), "%s/cbm-app-slice-%d-XXXXXX", cbm_tmpdir(), i);
|
||||
roots_ok = roots_ok && cbm_mkdtemp(roots[i]) != NULL;
|
||||
}
|
||||
app_index_thread_t requests[2] = {
|
||||
{.application = application, .project = "slice-first", .root = roots[0], .result = -1},
|
||||
{.application = application, .project = "slice-second", .root = roots[1], .result = -1},
|
||||
};
|
||||
cbm_thread_t threads[2];
|
||||
bool first_started = application && roots_ok &&
|
||||
cbm_thread_create(&threads[0], 0, app_index_thread, &requests[0]) == 0;
|
||||
bool first_spawned = first_started && app_wait_for_atomic_int(&fake.starts, 1);
|
||||
bool second_started =
|
||||
first_spawned && cbm_thread_create(&threads[1], 0, app_index_thread, &requests[1]) == 0;
|
||||
bool second_spawned = second_started && app_wait_for_atomic_int(&fake.starts, 2);
|
||||
atomic_store(&fake.allow_completion, true);
|
||||
if (first_started) {
|
||||
(void)cbm_thread_join(&threads[0]);
|
||||
}
|
||||
if (second_started) {
|
||||
(void)cbm_thread_join(&threads[1]);
|
||||
}
|
||||
bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
|
||||
cbm_daemon_application_free(application);
|
||||
for (int i = 0; i < 2; i++) {
|
||||
(void)cbm_rmdir(roots[i]);
|
||||
}
|
||||
|
||||
ASSERT_TRUE(roots_ok);
|
||||
ASSERT_TRUE(first_spawned);
|
||||
ASSERT_TRUE(second_spawned);
|
||||
ASSERT_EQ(requests[0].result, 0);
|
||||
ASSERT_EQ(requests[1].result, 0);
|
||||
ASSERT_TRUE(stopped);
|
||||
ASSERT_EQ(fake.memory_budgets[0], aggregate_budget);
|
||||
ASSERT_EQ(fake.memory_budgets[1], aggregate_budget / 2);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Decision A (#1997 #832): the over-budget verdict travels as a CLEAN worker
|
||||
* exit carrying an error response. The daemon passes that response through
|
||||
* verbatim on the first attempt — no crash/hang recovery loop, no quarantine
|
||||
* of innocent files — because a healthy process reported an honest failure. */
|
||||
TEST(daemon_application_over_budget_response_passes_through_without_recovery) {
|
||||
static const char over_budget_response[] =
|
||||
"{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"project\\\":\\\"budget\\\","
|
||||
"\\\"status\\\":\\\"error\\\",\\\"reason\\\":\\\"over_memory_budget\\\","
|
||||
"\\\"previous_index\\\":\\\"preserved\\\"}\"}],\"isError\":true}";
|
||||
app_fake_worker_context_t fake;
|
||||
app_fake_worker_context_init(&fake);
|
||||
atomic_store(&fake.scripted, true);
|
||||
fake.outcomes[0] = CBM_PROC_CLEAN;
|
||||
fake.responses[0] = over_budget_response;
|
||||
fake.outcomes[1] = CBM_PROC_CLEAN; /* a second start would be the recovery loop */
|
||||
cbm_daemon_application_worker_ops_t worker_ops = {
|
||||
.context = &fake,
|
||||
.start = app_fake_worker_start,
|
||||
.poll = app_fake_worker_poll,
|
||||
.cancel = app_fake_worker_cancel,
|
||||
.log_path = app_fake_worker_log_path,
|
||||
.destroy = app_fake_worker_destroy,
|
||||
};
|
||||
cbm_daemon_application_config_t config = {.worker_ops = &worker_ops};
|
||||
cbm_daemon_application_t *application = cbm_daemon_application_new(&config);
|
||||
cbm_daemon_runtime_application_callbacks_t callbacks =
|
||||
cbm_daemon_application_runtime_callbacks(application);
|
||||
cbm_daemon_runtime_application_session_t *session =
|
||||
application ? app_test_open(&callbacks, 71) : NULL;
|
||||
char root[APP_TEST_PATH_CAP];
|
||||
snprintf(root, sizeof(root), "%s/cbm-app-over-budget-XXXXXX", cbm_tmpdir());
|
||||
bool root_ok = cbm_mkdtemp(root) != NULL;
|
||||
uint8_t *context = NULL;
|
||||
uint32_t context_length = 0;
|
||||
char args[APP_TEST_PATH_CAP + 32];
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root);
|
||||
uint8_t *tool = NULL;
|
||||
uint32_t tool_length = 0;
|
||||
bool setup = application && session && root_ok &&
|
||||
app_test_context_request(root, root, &context, &context_length) &&
|
||||
app_test_tool_request("index_repository", args, &tool, &tool_length);
|
||||
uint8_t *response = NULL;
|
||||
uint32_t response_length = 0;
|
||||
if (setup) {
|
||||
setup = app_test_request(&callbacks, session, context, context_length, &response,
|
||||
&response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK;
|
||||
free(response);
|
||||
response = NULL;
|
||||
response_length = 0;
|
||||
}
|
||||
cbm_daemon_runtime_application_status_t status =
|
||||
setup
|
||||
? app_test_request(&callbacks, session, tool, tool_length, &response, &response_length)
|
||||
: CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
|
||||
bool passed_through = response && response_length > 0 &&
|
||||
strstr((char *)response, "over_memory_budget") != NULL &&
|
||||
strstr((char *)response, "\"isError\":true") != NULL;
|
||||
free(response);
|
||||
free(context);
|
||||
free(tool);
|
||||
if (session) {
|
||||
callbacks.session_cancel(callbacks.context, session);
|
||||
callbacks.session_close(callbacks.context, session);
|
||||
}
|
||||
bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
|
||||
cbm_daemon_application_free(application);
|
||||
(void)cbm_rmdir(root);
|
||||
|
||||
ASSERT_TRUE(setup);
|
||||
ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_OK);
|
||||
ASSERT_TRUE(passed_through);
|
||||
ASSERT_EQ(atomic_load(&fake.starts), 1);
|
||||
ASSERT_EQ(atomic_load(&fake.destroys), 1);
|
||||
ASSERT_TRUE(stopped);
|
||||
PASS();
|
||||
}
|
||||
|
||||
@@ -5458,6 +5603,8 @@ SUITE(daemon_application) {
|
||||
RUN_TEST(daemon_application_thread_start_failure_rolls_back_job_reservation);
|
||||
RUN_TEST(daemon_application_queues_explicit_index_behind_physical_job_limit);
|
||||
RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth);
|
||||
RUN_TEST(daemon_application_worker_slice_is_aggregate_over_active_jobs);
|
||||
RUN_TEST(daemon_application_over_budget_response_passes_through_without_recovery);
|
||||
RUN_TEST(daemon_application_free_reports_retained_live_ownership);
|
||||
RUN_TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,20 @@ static int reformat_files(const char *subdir, int max_files) {
|
||||
static char *index_repo(void) {
|
||||
char args[512];
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", g_repodir);
|
||||
return cbm_mcp_handle_tool(g_srv, "index_repository", args);
|
||||
char *resp = cbm_mcp_handle_tool(g_srv, "index_repository", args);
|
||||
/* Fail-closed at the source: every caller asserts resp != NULL, but an
|
||||
* error response (e.g. a transient pre-publication abort that preserves
|
||||
* the previous index) used to slip through and cascade into dozens of
|
||||
* mysterious count/query failures downstream — a 99-test pile-up on the
|
||||
* Windows leg traced back to exactly this. Surface the real cause once,
|
||||
* here, and let the existing non-NULL asserts fail loudly. */
|
||||
if (resp && strstr(resp, "\"isError\":true")) {
|
||||
fprintf(stderr, " index_repo: index_repository returned an error response:\n %.400s\n",
|
||||
resp);
|
||||
free(resp);
|
||||
return NULL;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
/* Timed index: returns response, sets *elapsed_ms and *peak_rss_mb */
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../src/foundation/constants.h"
|
||||
#include "../src/foundation/log.h"
|
||||
#include "../src/foundation/platform.h" /* cbm_file_size */
|
||||
#include "../src/foundation/mem.h" /* cbm_mem_set_budget_for_tests — over-budget seam */
|
||||
#include "../src/foundation/subprocess.h"
|
||||
#include "../src/foundation/workspace.h"
|
||||
#include "../src/mcp/compact_out.h"
|
||||
@@ -17670,6 +17671,161 @@ static int idx823_supervised_name_override_check(const char *repo_dir, const cha
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Write the 64-file Go fixture (above MIN_FILES_FOR_PARALLEL, so the gated
|
||||
* parallel extract path runs). generation 2 gives every file a second
|
||||
* definition so a re-run must re-extract all of them — an unchanged repo is an
|
||||
* incremental no-op and never reaches the memory gate. */
|
||||
static bool budget_fixture_write(const char *repo, int generation) {
|
||||
for (int i = 0; i < 64; i++) {
|
||||
char path[CBM_SZ_1K];
|
||||
char body[CBM_SZ_256];
|
||||
snprintf(path, sizeof(path), "%s/f%02d.go", repo, i);
|
||||
if (generation == 1) {
|
||||
snprintf(body, sizeof(body), "package main\n\nfunc F%02d() int {\n\treturn %d\n}\n", i,
|
||||
i);
|
||||
} else {
|
||||
snprintf(body, sizeof(body),
|
||||
"package main\n\nfunc F%02d() int {\n\treturn %d\n}\n\n"
|
||||
"func G%02d() int {\n\treturn F%02d() + 1\n}\n",
|
||||
i, i, i, i);
|
||||
}
|
||||
if (th_write_file(path, body) != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Parse the JSON object inside an MCP tool result. NULL when absent. */
|
||||
static yyjson_doc *budget_result_doc(const char *result) {
|
||||
char *text = extract_text_content(result);
|
||||
yyjson_doc *doc = text ? yyjson_read(text, strlen(text), 0) : NULL;
|
||||
free(text);
|
||||
return doc;
|
||||
}
|
||||
|
||||
static const char *budget_doc_str(yyjson_doc *doc, const char *key) {
|
||||
return doc ? yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), key)) : NULL;
|
||||
}
|
||||
|
||||
static int budget_doc_int(yyjson_doc *doc, const char *key, int missing) {
|
||||
yyjson_val *val = doc ? yyjson_obj_get(yyjson_doc_get_root(doc), key) : NULL;
|
||||
return val && yyjson_is_int(val) ? (int)yyjson_get_int(val) : missing;
|
||||
}
|
||||
|
||||
/* Decision A (#1997 #832): an attempt that stays over the memory budget after
|
||||
* back-pressure fails WHOLE and the response says so — reason, numbers, hint,
|
||||
* and that the previous index still serves — instead of the generic "check
|
||||
* repo_path" error. In-process: the runner is never a marked supervisor host,
|
||||
* and the budget seam stands in for a 1 MiB machine. */
|
||||
TEST(index_repository_over_budget_reports_named_reason) {
|
||||
char repo[CBM_SZ_1K];
|
||||
char cache[CBM_SZ_1K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-mcp-budget-repo-XXXXXX", cbm_tmpdir());
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-budget-cache-XXXXXX", cbm_tmpdir());
|
||||
if (!cbm_mkdtemp(repo)) {
|
||||
FAIL("cbm_mkdtemp repo failed");
|
||||
}
|
||||
if (!cbm_mkdtemp(cache)) {
|
||||
th_rmtree(repo);
|
||||
FAIL("cbm_mkdtemp cache failed");
|
||||
}
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
const char *saved_workers = getenv("CBM_WORKERS");
|
||||
char *saved_workers_copy = saved_workers ? strdup(saved_workers) : NULL;
|
||||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||||
cbm_setenv("CBM_WORKERS", "4", 1);
|
||||
|
||||
bool files_ok = budget_fixture_write(repo, 1);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
bool had_server = srv != NULL;
|
||||
char args[CBM_SZ_2K];
|
||||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", repo);
|
||||
|
||||
/* Generation 1 at the process budget publishes. */
|
||||
char *first = srv && files_ok ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL;
|
||||
yyjson_doc *first_doc = budget_result_doc(first);
|
||||
const char *first_status = budget_doc_str(first_doc, "status");
|
||||
bool first_indexed = first_status && strcmp(first_status, "indexed") == 0;
|
||||
int nodes_first = budget_doc_int(first_doc, "nodes", -1);
|
||||
const char *first_project = budget_doc_str(first_doc, "project");
|
||||
char *project = first_project ? strdup(first_project) : NULL;
|
||||
yyjson_doc_free(first_doc);
|
||||
free(first);
|
||||
char db_path[CBM_SZ_2K];
|
||||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing");
|
||||
long db_size_before = (long)cbm_file_size(db_path);
|
||||
|
||||
/* Generation 2 at 1 MiB over a changed repo fails whole. */
|
||||
files_ok = files_ok && budget_fixture_write(repo, 2);
|
||||
size_t saved_budget = cbm_mem_budget();
|
||||
cbm_mem_set_budget_for_tests((size_t)1024 * 1024);
|
||||
char *second = srv && files_ok ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL;
|
||||
cbm_mem_set_budget_for_tests(saved_budget);
|
||||
bool second_is_error = second && strstr(second, "\"isError\":true") != NULL;
|
||||
yyjson_doc *second_doc = budget_result_doc(second);
|
||||
const char *second_status = budget_doc_str(second_doc, "status");
|
||||
const char *second_reason = budget_doc_str(second_doc, "reason");
|
||||
const char *second_previous = budget_doc_str(second_doc, "previous_index");
|
||||
const char *second_hint = budget_doc_str(second_doc, "hint");
|
||||
bool status_error = second_status && strcmp(second_status, "error") == 0;
|
||||
bool reason_named = second_reason && strcmp(second_reason, "over_memory_budget") == 0;
|
||||
bool previous_preserved = second_previous && strcmp(second_previous, "preserved") == 0;
|
||||
bool hint_names_knob = second_hint && strstr(second_hint, "CBM_MEM_BUDGET_MB") != NULL;
|
||||
int budget_mb = budget_doc_int(second_doc, "budget_mb", -1);
|
||||
int peak_rss_mb = budget_doc_int(second_doc, "peak_rss_mb", -1);
|
||||
yyjson_doc_free(second_doc);
|
||||
free(second);
|
||||
long db_size_after = (long)cbm_file_size(db_path);
|
||||
|
||||
/* The previous generation still answers with its full graph. */
|
||||
char status_args[CBM_SZ_2K];
|
||||
snprintf(status_args, sizeof(status_args), "{\"project\":\"%s\"}", project ? project : "");
|
||||
char *status = srv && project ? cbm_mcp_handle_tool(srv, "index_status", status_args) : NULL;
|
||||
/* index_status answers in the tree format ("nodes: <n>" on its own line). */
|
||||
char *status_text = extract_text_content(status);
|
||||
const char *nodes_line = status_text ? strstr(status_text, "\nnodes: ") : NULL;
|
||||
int nodes_after = nodes_line ? (int)strtol(nodes_line + strlen("\nnodes: "), NULL, 10) : -1;
|
||||
if (nodes_after != nodes_first) {
|
||||
printf(" index_status after the failed attempt: %.400s\n",
|
||||
status_text ? status_text : "(null)");
|
||||
}
|
||||
free(status_text);
|
||||
free(status);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_project_db(cache, project);
|
||||
free(project);
|
||||
if (saved_workers_copy) {
|
||||
cbm_setenv("CBM_WORKERS", saved_workers_copy, 1);
|
||||
} else {
|
||||
cbm_unsetenv("CBM_WORKERS");
|
||||
}
|
||||
free(saved_workers_copy);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
th_rmtree(cache);
|
||||
th_rmtree(repo);
|
||||
|
||||
ASSERT_TRUE(files_ok);
|
||||
ASSERT_TRUE(had_server);
|
||||
ASSERT_TRUE(first_indexed);
|
||||
ASSERT_GT(nodes_first, 0);
|
||||
ASSERT_TRUE(second_is_error);
|
||||
ASSERT_TRUE(status_error);
|
||||
ASSERT_TRUE(reason_named);
|
||||
ASSERT_TRUE(previous_preserved);
|
||||
ASSERT_TRUE(hint_names_knob);
|
||||
ASSERT_EQ(budget_mb, 1);
|
||||
ASSERT_GT(peak_rss_mb, budget_mb);
|
||||
/* Preserved on disk and still served: same file size, same node count. */
|
||||
ASSERT_GT(db_size_before, 0L);
|
||||
ASSERT_EQ(db_size_after, db_size_before);
|
||||
ASSERT_EQ(nodes_after, nodes_first);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(index_repository_cli_name_override_issue823) {
|
||||
#ifdef _WIN32
|
||||
SKIP_PLATFORM("POSIX fork harness required to isolate supervisor host mark");
|
||||
@@ -20238,6 +20394,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(index_repository_relative_path_uses_explicit_session_root);
|
||||
RUN_TEST(index_repository_supervisor_uses_canonical_session_path);
|
||||
RUN_TEST(index_repository_cli_name_override_issue823);
|
||||
RUN_TEST(index_repository_over_budget_reports_named_reason);
|
||||
RUN_TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery);
|
||||
RUN_TEST(index_supervisor_gate_requires_marked_host_issue845);
|
||||
RUN_TEST(index_supervisor_start_failure_is_fail_closed_in_real_host);
|
||||
|
||||
+168
-8
@@ -12871,23 +12871,82 @@ TEST(pipeline_committed_counts_match_persisted) {
|
||||
* MIN_FILES_FOR_PARALLEL (50) — else the run routes sequential, the gate never
|
||||
* fires, and the test would pass vacuously (cycles==0). The engagement assert
|
||||
* below (cycles >= 1) is a hard guard against that regressing silently. */
|
||||
TEST(pipeline_backpressure_futile_nap_disengages) {
|
||||
/* 64 tiny files: > MIN_FILES_FOR_PARALLEL (50) so the parallel path (and its
|
||||
* back-pressure gate) actually runs; old-code cycles (~64) >> the bound. */
|
||||
/* Shared fixture for the back-pressure tests: 64 tiny Go files, above
|
||||
* MIN_FILES_FOR_PARALLEL (50) so the parallel extract path — the only phase
|
||||
* with a memory gate — is the one that runs. */
|
||||
static bool write_backpressure_fixture(void) {
|
||||
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_test_XXXXXX");
|
||||
if (!cbm_mkdtemp(g_tmpdir)) {
|
||||
FAIL("failed to create temp dir");
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 64; i++) {
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%s/f%02d.go", g_tmpdir, i);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
FAIL("failed to create fixture file");
|
||||
return false;
|
||||
}
|
||||
fprintf(f, "package main\n\nfunc F%02d() int {\n\treturn %d\n}\n", i, i);
|
||||
fclose(f);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Rewrite every fixture file with a second definition. An unchanged repo
|
||||
* routes incremental_manifest → incremental.noop (nothing is re-extracted, so
|
||||
* no gate can fire); changing all 64 files makes the next run re-extract them
|
||||
* all through the parallel path. */
|
||||
static bool mutate_backpressure_fixture(void) {
|
||||
for (int i = 0; i < 64; i++) {
|
||||
char path[512];
|
||||
snprintf(path, sizeof(path), "%s/f%02d.go", g_tmpdir, i);
|
||||
FILE *f = fopen(path, "w");
|
||||
if (!f) {
|
||||
return false;
|
||||
}
|
||||
fprintf(f,
|
||||
"package main\n\nfunc F%02d() int {\n\treturn %d\n}\n\n"
|
||||
"func G%02d() int {\n\treturn F%02d() + 1\n}\n",
|
||||
i, i, i, i);
|
||||
fclose(f);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Whole-file read for byte-identity assertions on a published database. */
|
||||
static unsigned char *read_file_bytes(const char *path, size_t *len_out) {
|
||||
*len_out = 0;
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
}
|
||||
long size = fseek(f, 0, SEEK_END) == 0 ? ftell(f) : -1;
|
||||
if (size < 0) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
rewind(f);
|
||||
unsigned char *buf = malloc((size_t)size + 1);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
size_t got = fread(buf, 1, (size_t)size, f);
|
||||
fclose(f);
|
||||
if (got != (size_t)size) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
*len_out = got;
|
||||
return buf;
|
||||
}
|
||||
|
||||
TEST(pipeline_backpressure_futile_nap_disengages) {
|
||||
/* 64 tiny files: > MIN_FILES_FOR_PARALLEL (50) so the parallel path (and its
|
||||
* back-pressure gate) actually runs; old-code cycles (~64) >> the bound. */
|
||||
if (!write_backpressure_fixture()) {
|
||||
FAIL("failed to create fixture");
|
||||
}
|
||||
|
||||
/* 1 MB budget: over-budget on every pull, unreclaimable by napping.
|
||||
* Set via the test hook, NOT setenv + cbm_mem_init: the init-once guard
|
||||
@@ -12942,7 +13001,10 @@ TEST(pipeline_backpressure_futile_nap_disengages) {
|
||||
teardown_test_repo();
|
||||
|
||||
ASSERT_EQ(restore_workers_rc, 0);
|
||||
ASSERT_EQ(rc, 0);
|
||||
/* Decision A (#1997 #832): a budget that never drains is not a soft
|
||||
* overshoot any more — after the latch and ONE confirmation cycle the
|
||||
* attempt fails whole with the named code (rc==0 was the advisory era). */
|
||||
ASSERT_EQ(rc, CBM_PIPELINE_ABORT_OVER_BUDGET);
|
||||
/* Engagement guard (anti-vacuous): the gate must have actually run — the
|
||||
* parallel path taken and the 1 MB budget exceeded on every pull. cycles==0
|
||||
* means the fixture routed sequential (or the gate was compiled out) and
|
||||
@@ -12951,8 +13013,9 @@ TEST(pipeline_backpressure_futile_nap_disengages) {
|
||||
FAIL("back-pressure gate never engaged (cycles==0) — fixture routed sequential?");
|
||||
}
|
||||
/* Futile napping must disengage: at most one in-flight cycle per worker
|
||||
* plus a small margin, never one per file (64). */
|
||||
long bound = TEST_WORKERS + 2;
|
||||
* plus the single confirmation cycle and a small margin, never one per
|
||||
* file (64). */
|
||||
long bound = TEST_WORKERS + 3;
|
||||
if (cycles > bound) {
|
||||
char msg[128];
|
||||
snprintf(msg, sizeof(msg), "nap cycles %ld > bound %ld (gate re-paid per pull)", cycles,
|
||||
@@ -12962,6 +13025,102 @@ TEST(pipeline_backpressure_futile_nap_disengages) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Decision A (#1997 #832): once back-pressure is futile AND one confirmation
|
||||
* cycle still ends over budget, the attempt fails WHOLE with a named code —
|
||||
* no partial graph is published, no stage residue remains, and the previously
|
||||
* serving generation is byte-identical. */
|
||||
TEST(pipeline_over_budget_after_futility_fails_whole_and_preserves_db) {
|
||||
if (!write_backpressure_fixture()) {
|
||||
FAIL("failed to create fixture");
|
||||
}
|
||||
enum { TEST_WORKERS = 4 };
|
||||
const char *old_workers = getenv("CBM_WORKERS");
|
||||
char *saved_workers = old_workers ? strdup(old_workers) : NULL;
|
||||
if ((old_workers && !saved_workers) || cbm_setenv("CBM_WORKERS", "4", 1) != 0) {
|
||||
free(saved_workers);
|
||||
teardown_test_repo();
|
||||
FAIL("failed to pin CBM_WORKERS");
|
||||
}
|
||||
|
||||
char db_path[512];
|
||||
snprintf(db_path, sizeof(db_path), "%s/budget.db", g_tmpdir);
|
||||
|
||||
/* Generation 1 at the process budget: publishes normally. */
|
||||
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||||
int rc_first = p ? cbm_pipeline_run(p) : -1;
|
||||
char *project = p ? strdup(cbm_pipeline_project_name(p)) : NULL;
|
||||
cbm_pipeline_free(p);
|
||||
int nodes_before = -1;
|
||||
bool valid_before = false;
|
||||
cbm_store_t *live = rc_first == 0 && project ? cbm_store_open_path(db_path) : NULL;
|
||||
if (live) {
|
||||
valid_before = cbm_store_check_integrity(live);
|
||||
nodes_before = cbm_store_count_nodes(live, project);
|
||||
cbm_store_close(live);
|
||||
}
|
||||
size_t before_len = 0;
|
||||
unsigned char *before = read_file_bytes(db_path, &before_len);
|
||||
|
||||
/* Generation 2 at a 1 MiB budget over a CHANGED repo (every file gains a
|
||||
* definition, so the run must re-extract all 64): over budget on every
|
||||
* probe and unreclaimable by napping, so futility latches and the
|
||||
* confirmation cycle still ends over budget. */
|
||||
bool mutated = mutate_backpressure_fixture();
|
||||
size_t saved_budget = cbm_mem_budget();
|
||||
cbm_mem_set_budget_for_tests((size_t)1024 * 1024);
|
||||
bool over_at_start = cbm_mem_over_budget();
|
||||
cbm_pp_bp_nap_cycles_reset();
|
||||
p = cbm_pipeline_new(g_tmpdir, db_path, CBM_MODE_FULL);
|
||||
int rc_second = p ? cbm_pipeline_run(p) : -1;
|
||||
long cycles = cbm_pp_bp_nap_cycles();
|
||||
/* Restore the caller-visible budget BEFORE any assertion. */
|
||||
cbm_mem_set_budget_for_tests(saved_budget);
|
||||
cbm_pipeline_free(p);
|
||||
|
||||
size_t after_len = 0;
|
||||
unsigned char *after = read_file_bytes(db_path, &after_len);
|
||||
int stage_count = count_generation_stage_artifacts(g_tmpdir, "budget.db");
|
||||
int nodes_after = -1;
|
||||
bool valid_after = false;
|
||||
live = cbm_store_open_path(db_path);
|
||||
if (live) {
|
||||
valid_after = cbm_store_check_integrity(live);
|
||||
nodes_after = project ? cbm_store_count_nodes(live, project) : -1;
|
||||
cbm_store_close(live);
|
||||
}
|
||||
bool bytes_identical =
|
||||
before && after && before_len == after_len && memcmp(before, after, before_len) == 0;
|
||||
free(before);
|
||||
free(after);
|
||||
free(project);
|
||||
int restore_workers_rc =
|
||||
saved_workers ? cbm_setenv("CBM_WORKERS", saved_workers, 1) : cbm_unsetenv("CBM_WORKERS");
|
||||
free(saved_workers);
|
||||
teardown_test_repo();
|
||||
|
||||
ASSERT_EQ(restore_workers_rc, 0);
|
||||
ASSERT_EQ(rc_first, 0);
|
||||
ASSERT_TRUE(valid_before);
|
||||
ASSERT_GT(nodes_before, 0);
|
||||
ASSERT_TRUE(before_len > 0);
|
||||
ASSERT_TRUE(mutated);
|
||||
ASSERT_TRUE(over_at_start);
|
||||
/* The named failure — not the cancel sentinel, not success. */
|
||||
ASSERT_EQ(rc_second, CBM_PIPELINE_ABORT_OVER_BUDGET);
|
||||
/* Engagement guard (anti-vacuous): fail-whole needs the latching cycle AND
|
||||
* the confirmation cycle; fewer means the gate never decided anything. */
|
||||
if (cycles < 2) {
|
||||
FAIL("back-pressure gate never confirmed futility (cycles<2) — fixture routed sequential?");
|
||||
}
|
||||
/* The serving generation is untouched: same bytes, still valid, same
|
||||
* graph; and the failed attempt left no stage residue behind. */
|
||||
ASSERT_TRUE(bytes_identical);
|
||||
ASSERT_TRUE(valid_after);
|
||||
ASSERT_EQ(nodes_after, nodes_before);
|
||||
ASSERT_EQ(stage_count, 0);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* TS cross-registry test hooks (ts_lsp.c) — extern to avoid pulling the
|
||||
* tree-sitter-typed ts_lsp.h into this store-level test. */
|
||||
extern long cbm_ts_full_registry_builds(void);
|
||||
@@ -13774,6 +13933,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(pipeline_run_null);
|
||||
/* Extraction back-pressure */
|
||||
RUN_TEST(pipeline_backpressure_futile_nap_disengages);
|
||||
RUN_TEST(pipeline_over_budget_after_futility_fails_whole_and_preserves_db);
|
||||
/* Sequential cross-LSP shared registry (ms-typescript quadratic) */
|
||||
RUN_TEST(pipeline_seq_ts_cross_uses_shared_registry);
|
||||
/* File persistence */
|
||||
|
||||
Reference in New Issue
Block a user