Merge pull request #1866 from DeusData/fix/issue-474-search-code-scan-deadline

fix(mcp): bound search_code scan deadlines
This commit is contained in:
Martin Vogel
2026-08-29 11:32:18 +02:00
committed by GitHub
4 changed files with 824 additions and 91 deletions
+270 -76
View File
@@ -129,6 +129,7 @@ enum {
#define MCP_MAX_MESSAGE_SIZE ((size_t)10U * 1024U * 1024U)
#define MCP_MAX_HEADER_SIZE ((size_t)8U * 1024U)
#define MCP_SEARCH_OUTPUT_MAX ((size_t)64U * 1024U * 1024U)
#define MCP_SEARCH_SCAN_TIMEOUT_MS ((uint64_t)30000U)
/* ── Helpers ────────────────────────────────────────────────────── */
@@ -1624,6 +1625,9 @@ struct cbm_mcp_server {
void *auto_index_count_test_context;
#endif
size_t search_output_limit_override;
const char *search_scan_command_override;
uint64_t search_scan_timeout_override_ms;
bool search_scan_timeout_override_set;
cbm_thread_t autoindex_tid;
bool autoindex_active; /* true if auto-index thread was started */
@@ -1939,6 +1943,20 @@ void cbm_mcp_server_set_search_output_limit_for_test(cbm_mcp_server_t *srv, size
}
}
void cbm_mcp_server_set_search_scan_command_for_test(cbm_mcp_server_t *srv, const char *command) {
if (srv) {
srv->search_scan_command_override = command;
}
}
void cbm_mcp_server_set_search_scan_timeout_for_test(cbm_mcp_server_t *srv, uint64_t timeout_ms,
bool override_set) {
if (srv) {
srv->search_scan_timeout_override_ms = timeout_ms;
srv->search_scan_timeout_override_set = override_set;
}
}
/* ── Cache dir + project DB path helpers ───────────────────────── */
/* Returns the cache directory. Writes to buf, returns buf for convenience. */
@@ -9513,11 +9531,16 @@ void cbm_search_code_build_grep_cmd(char *cmd, size_t cmd_sz, bool use_regex, bo
/* -0: read NUL-separated paths from the filelist so paths containing
* spaces stay one argument (issue #687). Pairs with the NUL separator
* written by write_scoped_filelist. */
snprintf(cmd, cmd_sz, "xargs -0 grep -Hn %s --include='%s' -f '%s' < '%s' 2>/dev/null",
snprintf(cmd, cmd_sz,
"xargs -0 sh -c 'grep -Hn -d skip %s --include=\"%s\" -f \"%s\" \"$@\"; "
"status=$?; [ \"$status\" -eq 0 ] || [ \"$status\" -eq 1 ]' sh < '%s' "
"2>/dev/null",
flag, file_pattern, tmpfile, filelist);
} else {
snprintf(cmd, cmd_sz, "xargs -0 grep -Hn %s -f '%s' < '%s' 2>/dev/null", flag, tmpfile,
filelist);
snprintf(cmd, cmd_sz,
"xargs -0 sh -c 'grep -Hn -d skip %s -f \"%s\" \"$@\"; status=$?; "
"[ \"$status\" -eq 0 ] || [ \"$status\" -eq 1 ]' sh < '%s' 2>/dev/null",
flag, tmpfile, filelist);
}
} else {
if (file_pattern) {
@@ -10118,13 +10141,18 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
}
char **indexed_files = NULL;
int indexed_count = 0;
if (cbm_store_list_files(pre_store, project, &indexed_files, &indexed_count) != CBM_STORE_OK ||
indexed_count == 0) {
int list_rc = cbm_store_list_files(pre_store, project, &indexed_files, &indexed_count);
if (list_rc != CBM_STORE_OK || indexed_count == 0) {
for (int fi = 0; fi < indexed_count; fi++) {
free(indexed_files[fi]);
}
free(indexed_files);
return false;
}
bool ok = false;
int written = 0;
if (fl) {
ok = true;
for (int fi = 0; fi < indexed_count; fi++) {
/* A source path never legitimately contains a newline or carriage
* return. Those bytes are exactly the record separator on the
@@ -10142,6 +10170,31 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
continue;
}
}
size_t root_len = strlen(root_path);
size_t file_len = strlen(indexed_files[fi]);
if (root_len > SIZE_MAX - file_len - 2) {
continue;
}
size_t scan_path_len = root_len + 1 + file_len;
char *scan_path = malloc(scan_path_len + 1);
if (!scan_path) {
ok = false;
break;
}
memcpy(scan_path, root_path, root_len);
scan_path[root_len] = '/';
memcpy(scan_path + root_len + 1, indexed_files[fi], file_len + 1);
/* Incremental stores can retain structural directory nodes and
* briefly stale deleted-file paths. Neither is a content-scan
* operand. Filter them before spawning so an expected stale entry
* cannot turn otherwise valid matches into grep status 2. This
* deliberately does not follow symlinks/reparse points. */
cbm_path_info_t path_info;
if (cbm_path_info_utf8(scan_path, &path_info) != 0 || !path_info.is_regular) {
free(scan_path);
continue;
}
/* Write "<root>/<file>" piece-by-piece (no fixed-size buffer, so an
* arbitrarily long absolute path cannot overflow). Forward slash join
* so xargs doesn't treat Windows backslashes as escapes; binary mode
@@ -10150,9 +10203,8 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
* newline separator would split plain xargs on the space).
* - Windows: newline, consumed by PowerShell `Get-Content |
* Select-String -LiteralPath` (NUL bytes break Get-Content). */
(void)fwrite(root_path, 1, strlen(root_path), fl);
(void)fputc('/', fl);
(void)fwrite(indexed_files[fi], 1, strlen(indexed_files[fi]), fl);
(void)fwrite(scan_path, 1, scan_path_len, fl);
free(scan_path);
#ifdef _WIN32
(void)fputc('\n', fl);
#else
@@ -10162,7 +10214,6 @@ static bool write_scoped_filelist(cbm_mcp_server_t *srv, const char *project, co
}
/* The stream stays open — the caller owns it and closes it (flushing
* these records to disk) before the grep subprocess reads the list. */
ok = true;
}
for (int fi = 0; fi < indexed_count; fi++) {
free(indexed_files[fi]);
@@ -10266,6 +10317,16 @@ typedef struct {
FILE *filelist; /* held open for write_scoped_filelist; closed by the caller */
} search_scratch_t;
typedef enum {
MCP_SCAN_SUCCESS = 0,
MCP_SCAN_COMMAND_FAILURE,
MCP_SCAN_CONTAINED_COMMAND_FAILURE,
MCP_SCAN_OUTPUT_LIMIT,
MCP_SCAN_DEADLINE,
MCP_SCAN_CANCELLED,
MCP_SCAN_SUPERVISION_FAILURE,
} mcp_scan_cause_t;
/* Create <scratch>/<basename>-XXXXXX exclusively and return a stream on the
* descriptor. On failure `path_out` is emptied so cleanup skips it. */
static FILE *search_scratch_file(const char *dir, const char *basename, char *path_out,
@@ -10362,17 +10423,44 @@ static bool compile_path_filter(const char *filter, cbm_regex_t *re) {
return cbm_regcomp(re, filter, CBM_REG_EXTENDED | CBM_REG_NOSUB) == CBM_REG_OK;
}
static int mcp_run_shell_command_cancellable_bounded(cbm_mcp_server_t *srv, const char *command,
char output_path[CBM_SZ_2K],
size_t output_limit,
bool *output_limit_exceeded,
cbm_proc_result_t *result_out);
static mcp_scan_cause_t mcp_run_shell_command_cancellable_bounded(
cbm_mcp_server_t *srv, const char *command, char output_path[CBM_SZ_2K], size_t output_limit,
uint64_t deadline_ms, bool deadline_enabled, bool deadline_latched, bool exit_one_is_no_match,
cbm_proc_result_t *result_out);
static char *search_code_timeout_result(void) {
static const char message[] = "search_code scan exceeded its execution deadline";
static const char fallback[] =
"{\"content\":[{\"type\":\"text\",\"text\":\"search_code scan exceeded its execution "
"deadline\"}],\"structuredContent\":{\"code\":\"request_timeout\",\"message\":\"search_"
"code "
"scan exceeded its execution deadline\"},\"isError\":true}";
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
if (!doc) {
return heap_strdup(fallback);
}
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_val *content = yyjson_mut_arr(doc);
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "type", "text");
yyjson_mut_obj_add_str(doc, item, "text", message);
yyjson_mut_arr_add_val(content, item);
yyjson_mut_obj_add_val(doc, root, "content", content);
yyjson_mut_val *structured = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, structured, "code", "request_timeout");
yyjson_mut_obj_add_str(doc, structured, "message", message);
yyjson_mut_obj_add_val(doc, root, "structuredContent", structured);
yyjson_mut_obj_add_bool(doc, root, "isError", true);
char *result = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return result ? result : heap_strdup(fallback);
}
#ifdef _WIN32
static char *search_code_scan_error(search_scratch_t *scratch, const char *output_path,
bool has_path_filter, cbm_regex_t *path_regex, char *root_path,
char *pattern, char *project, char *file_pattern,
const char *message) {
mcp_scan_cause_t cause, const char *message) {
if (output_path && output_path[0]) {
(void)cbm_unlink(output_path);
}
@@ -10384,9 +10472,11 @@ static char *search_code_scan_error(search_scratch_t *scratch, const char *outpu
free(pattern);
free(project);
free(file_pattern);
if (cause == MCP_SCAN_DEADLINE) {
return search_code_timeout_result();
}
return cbm_mcp_text_result(message, true);
}
#endif
static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
char *pattern = cbm_mcp_get_string_arg(args, "pattern");
@@ -10516,8 +10606,18 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
}
/* ── Phase 1: Grep scan ──────────────────────────────────── */
uint64_t scan_budget_ms = srv->search_scan_timeout_override_set
? srv->search_scan_timeout_override_ms
: MCP_SEARCH_SCAN_TIMEOUT_MS;
uint64_t scan_started_ms = cbm_now_ms();
uint64_t scan_deadline_ms = UINT64_MAX - scan_started_ms < scan_budget_ms
? UINT64_MAX
: scan_started_ms + scan_budget_ms;
bool scan_deadline_latched = false;
search_scratch_t scratch;
if (!search_scratch_open(&scratch, pattern)) {
bool scan_cancelled = mcp_request_cancelled(srv);
bool scan_timed_out = cbm_now_ms() >= scan_deadline_ms;
char errmsg[CBM_SZ_256];
snprintf(errmsg, sizeof(errmsg), "search failed: cannot create temp file (%s)",
strerror(errno));
@@ -10525,8 +10625,16 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
free(pattern);
free(project);
free(file_pattern);
if (scan_cancelled) {
return cbm_mcp_text_result("search_code cancelled for this request", true);
}
if (scan_timed_out) {
return search_code_timeout_result();
}
return cbm_mcp_text_result(errmsg, true);
}
scan_deadline_latched = cbm_now_ms() >= scan_deadline_ms;
bool scan_cancellation_latched = mcp_request_cancelled(srv);
const char *tmpfile = scratch.pattern_path;
const char *filelist = scratch.filelist_path;
@@ -10544,13 +10652,17 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
int scoped_written = 0;
uint64_t scope_t0 = metrics.include_phase_timings ? cbm_now_ms() : 0;
scoped = write_scoped_filelist(srv, project, root_path, scratch.filelist, has_path_filter,
has_path_filter ? &path_regex : NULL, &scoped_written);
if (!scan_cancellation_latched && !scan_deadline_latched) {
scoped = write_scoped_filelist(srv, project, root_path, scratch.filelist, has_path_filter,
has_path_filter ? &path_regex : NULL, &scoped_written);
}
/* Close before grep runs: this is what flushes the records the helper wrote
* through the descriptor. Clearing the field hands ownership to
* search_scratch_close, which still unlinks the file itself. */
(void)fclose(scratch.filelist);
scratch.filelist = NULL;
scan_cancellation_latched = scan_cancellation_latched || mcp_request_cancelled(srv);
scan_deadline_latched = scan_deadline_latched || cbm_now_ms() >= scan_deadline_ms;
if (metrics.include_phase_timings) {
metrics.scope_ms = cbm_now_ms() - scope_t0;
}
@@ -10559,7 +10671,7 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
int gm_count = 0;
grep_match_t *gm = NULL;
uint64_t scan_t0 = metrics.include_phase_timings ? cbm_now_ms() : 0;
if (scoped && scoped_written == 0) {
if (scoped && scoped_written == 0 && !scan_cancellation_latched && !scan_deadline_latched) {
/* The path_filter excluded every indexed file — nothing to scan.
* Skip the grep subprocess: xargs on an empty filelist is
* platform-dependent (GNU execs grep once with no operands, BSD
@@ -10571,61 +10683,57 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
cbm_search_code_build_grep_cmd(cmd, sizeof(cmd), use_regex, scoped, file_pattern, tmpfile,
filelist, root_path);
#ifdef _WIN32
char output_path[CBM_SZ_2K] = {0};
cbm_proc_result_t scan_result = {0};
bool scan_output_exceeded = false;
size_t scan_output_limit = srv->search_output_limit_override
? srv->search_output_limit_override
: MCP_SEARCH_OUTPUT_MAX;
int scan_run = mcp_run_shell_command_cancellable_bounded(
srv, cmd, output_path, scan_output_limit, &scan_output_exceeded, &scan_result);
if (scan_output_exceeded) {
const char *scan_command =
srv->search_scan_command_override ? srv->search_scan_command_override : cmd;
mcp_scan_cause_t scan_cause = mcp_run_shell_command_cancellable_bounded(
srv, scan_command, output_path, scan_output_limit, scan_deadline_ms, true,
scan_deadline_latched, !scoped, &scan_result);
if (scan_cause == MCP_SCAN_SUPERVISION_FAILURE) {
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern, scan_cause,
"search failed: process supervision could not quiesce");
}
if (scan_cause == MCP_SCAN_CANCELLED) {
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern, scan_cause,
"search_code cancelled for this request");
}
if (scan_cause == MCP_SCAN_DEADLINE) {
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern, scan_cause,
NULL);
}
if (scan_cause == MCP_SCAN_OUTPUT_LIMIT) {
char message[CBM_SZ_128];
snprintf(message, sizeof(message),
"search failed: output exceeded the %zu-byte safety limit", scan_output_limit);
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern, message);
root_path, pattern, project, file_pattern, scan_cause,
message);
}
bool scan_cancelled = scan_result.cancellation_requested || mcp_request_cancelled(srv);
if (scan_cancelled) {
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern,
"search_code cancelled for this request");
}
if (scan_run != 0) {
if (scan_cause == MCP_SCAN_COMMAND_FAILURE ||
scan_cause == MCP_SCAN_CONTAINED_COMMAND_FAILURE) {
return search_code_scan_error(
&scratch, output_path, has_path_filter, &path_regex, root_path, pattern, project,
file_pattern, "search failed: the contained command could not complete");
file_pattern, scan_cause,
"search failed: the contained command could not complete");
}
FILE *fp = cbm_fopen(output_path, "rb");
if (!fp) {
return search_code_scan_error(&scratch, output_path, has_path_filter, &path_regex,
root_path, pattern, project, file_pattern,
MCP_SCAN_COMMAND_FAILURE,
"search failed: contained output could not be read");
}
gm = collect_grep_matches(fp, root_path, strlen(root_path), has_path_filter, &path_regex,
grep_limit, &gm_count);
(void)fclose(fp);
(void)cbm_unlink(output_path);
#else
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
search_scratch_close(&scratch);
if (has_path_filter) {
cbm_regfree(&path_regex);
}
free(root_path);
free(pattern);
free(project);
free(file_pattern);
return cbm_mcp_text_result("search failed", true);
}
gm = collect_grep_matches(fp, root_path, strlen(root_path), has_path_filter, &path_regex,
grep_limit, &gm_count);
cbm_pclose(fp);
#endif
/* Both scratch files and the private directory go here — unlike the old
* code, the file list is removed even when the scan was not scoped. */
search_scratch_close(&scratch);
@@ -10786,29 +10894,74 @@ static bool mcp_resolve_windows_cmd(char out[CBM_SZ_4K]) {
}
#endif
static int mcp_run_shell_command_cancellable_bounded(cbm_mcp_server_t *srv, const char *command,
char output_path[CBM_SZ_2K],
size_t output_limit,
bool *output_limit_exceeded,
cbm_proc_result_t *result_out) {
if (output_limit_exceeded) {
*output_limit_exceeded = false;
static mcp_scan_cause_t mcp_scan_pre_spawn_cause(cbm_mcp_server_t *srv, const char *output_path,
size_t output_limit, uint64_t deadline_ms,
bool deadline_enabled, bool *cancellation_latched,
bool *deadline_latched,
bool *output_limit_latched) {
*cancellation_latched = *cancellation_latched || mcp_request_cancelled(srv);
*deadline_latched = *deadline_latched || (deadline_enabled && cbm_now_ms() >= deadline_ms);
if (!*output_limit_latched && output_limit > 0 && output_path && output_path[0]) {
int64_t output_size = cbm_file_size(output_path);
*output_limit_latched = output_size > 0 && (uint64_t)output_size > output_limit;
}
if (!srv || !command || !output_path || !result_out ||
(output_limit > 0 && !output_limit_exceeded) || !mcp_command_output_path(output_path)) {
return -1;
if (*cancellation_latched) {
return MCP_SCAN_CANCELLED;
}
if (*deadline_latched) {
return MCP_SCAN_DEADLINE;
}
if (*output_limit_latched) {
return MCP_SCAN_OUTPUT_LIMIT;
}
return MCP_SCAN_SUCCESS;
}
static mcp_scan_cause_t mcp_run_shell_command_cancellable_bounded(
cbm_mcp_server_t *srv, const char *command, char output_path[CBM_SZ_2K], size_t output_limit,
uint64_t deadline_ms, bool deadline_enabled, bool deadline_latched, bool exit_one_is_no_match,
cbm_proc_result_t *result_out) {
if (!srv || !command || !output_path || !result_out) {
return MCP_SCAN_COMMAND_FAILURE;
}
memset(result_out, 0, sizeof(*result_out));
bool cancellation_latched = false;
bool output_limit_latched = false;
mcp_scan_cause_t pre_spawn_cause =
mcp_scan_pre_spawn_cause(srv, NULL, output_limit, deadline_ms, deadline_enabled,
&cancellation_latched, &deadline_latched, &output_limit_latched);
if (pre_spawn_cause != MCP_SCAN_SUCCESS) {
result_out->tree_quiesced = true; /* no child was spawned */
return pre_spawn_cause;
}
if (!mcp_command_output_path(output_path)) {
pre_spawn_cause = mcp_scan_pre_spawn_cause(srv, NULL, output_limit, deadline_ms,
deadline_enabled, &cancellation_latched,
&deadline_latched, &output_limit_latched);
result_out->tree_quiesced = true;
return pre_spawn_cause != MCP_SCAN_SUCCESS ? pre_spawn_cause : MCP_SCAN_COMMAND_FAILURE;
}
/* Internal test seam: rejecting after output allocation exercises the same
* cleanup contract as a contained process-tree failure. */
if (srv->command_test_hook && !srv->command_test_hook(srv->command_test_context, command)) {
return -1;
bool command_rejected =
srv->command_test_hook && !srv->command_test_hook(srv->command_test_context, command);
pre_spawn_cause =
mcp_scan_pre_spawn_cause(srv, output_path, output_limit, deadline_ms, deadline_enabled,
&cancellation_latched, &deadline_latched, &output_limit_latched);
if (pre_spawn_cause != MCP_SCAN_SUCCESS || command_rejected) {
result_out->tree_quiesced = true; /* no child was spawned */
return pre_spawn_cause != MCP_SCAN_SUCCESS ? pre_spawn_cause : MCP_SCAN_COMMAND_FAILURE;
}
#ifdef _WIN32
char shell[CBM_SZ_4K];
if (!mcp_resolve_windows_cmd(shell)) {
pre_spawn_cause = mcp_scan_pre_spawn_cause(srv, output_path, output_limit, deadline_ms,
deadline_enabled, &cancellation_latched,
&deadline_latched, &output_limit_latched);
(void)cbm_unlink(output_path);
output_path[0] = '\0';
return -1;
result_out->tree_quiesced = true;
return pre_spawn_cause != MCP_SCAN_SUCCESS ? pre_spawn_cause : MCP_SCAN_COMMAND_FAILURE;
}
#else
const char *shell = "/bin/sh";
@@ -10826,27 +10979,48 @@ static int mcp_run_shell_command_cancellable_bounded(cbm_mcp_server_t *srv, cons
.cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS,
.delete_log_on_exit = false,
};
pre_spawn_cause =
mcp_scan_pre_spawn_cause(srv, output_path, output_limit, deadline_ms, deadline_enabled,
&cancellation_latched, &deadline_latched, &output_limit_latched);
if (pre_spawn_cause != MCP_SCAN_SUCCESS) {
result_out->tree_quiesced = true;
return pre_spawn_cause;
}
cbm_subprocess_t *process = NULL;
if (cbm_subprocess_spawn(&options, &process) != 0) {
pre_spawn_cause = mcp_scan_pre_spawn_cause(srv, output_path, output_limit, deadline_ms,
deadline_enabled, &cancellation_latched,
&deadline_latched, &output_limit_latched);
(void)cbm_unlink(output_path);
output_path[0] = '\0';
return -1;
result_out->tree_quiesced = true;
return pre_spawn_cause != MCP_SCAN_SUCCESS ? pre_spawn_cause : MCP_SCAN_COMMAND_FAILURE;
}
cbm_proc_poll_t state;
bool limit_exceeded = false;
for (;;) {
if (mcp_request_cancelled(srv)) {
cancellation_latched = true;
(void)cbm_subprocess_request_cancel(process);
}
if (!limit_exceeded && output_limit > 0) {
if (deadline_enabled && cbm_now_ms() >= deadline_ms) {
deadline_latched = true;
(void)cbm_subprocess_request_cancel(process);
}
if (!output_limit_latched && output_limit > 0) {
int64_t output_size = cbm_file_size(output_path);
if (output_size > 0 && (uint64_t)output_size > output_limit) {
limit_exceeded = true;
output_limit_latched = true;
(void)cbm_subprocess_request_cancel(process);
}
}
state = cbm_subprocess_poll(process, result_out);
cancellation_latched = cancellation_latched || mcp_request_cancelled(srv);
deadline_latched = deadline_latched || (deadline_enabled && cbm_now_ms() >= deadline_ms);
if (!output_limit_latched && output_limit > 0) {
int64_t output_size = cbm_file_size(output_path);
output_limit_latched = output_size > 0 && (uint64_t)output_size > output_limit;
}
if (state != CBM_PROC_POLL_RUNNING) {
break;
}
@@ -10855,21 +11029,41 @@ static int mcp_run_shell_command_cancellable_bounded(cbm_mcp_server_t *srv, cons
bool contained = state == CBM_PROC_POLL_TERMINAL && result_out->tree_quiesced &&
!result_out->supervision_failed;
cbm_subprocess_destroy(process);
if (!limit_exceeded && output_limit > 0) {
if (!output_limit_latched && output_limit > 0) {
int64_t final_size = cbm_file_size(output_path);
limit_exceeded = final_size > 0 && (uint64_t)final_size > output_limit;
output_limit_latched = final_size > 0 && (uint64_t)final_size > output_limit;
}
if (output_limit_exceeded) {
*output_limit_exceeded = limit_exceeded;
if (!contained) {
return MCP_SCAN_SUPERVISION_FAILURE;
}
return contained ? 0 : -1;
if (cancellation_latched) {
return MCP_SCAN_CANCELLED;
}
if (deadline_latched) {
return MCP_SCAN_DEADLINE;
}
if (output_limit_latched) {
return MCP_SCAN_OUTPUT_LIMIT;
}
#ifndef _WIN32
if (exit_one_is_no_match && result_out->outcome == CBM_PROC_EXIT_NONZERO &&
result_out->exit_code == 1) {
return MCP_SCAN_SUCCESS; /* ordinary direct grep no-match */
}
#endif
return result_out->outcome == CBM_PROC_CLEAN ? MCP_SCAN_SUCCESS
: MCP_SCAN_CONTAINED_COMMAND_FAILURE;
}
static int mcp_run_shell_command_cancellable(cbm_mcp_server_t *srv, const char *command,
char output_path[CBM_SZ_2K],
cbm_proc_result_t *result_out) {
return mcp_run_shell_command_cancellable_bounded(srv, command, output_path, 0, NULL,
result_out);
mcp_scan_cause_t cause = mcp_run_shell_command_cancellable_bounded(
srv, command, output_path, 0, 0, false, false, false, result_out);
/* Legacy callers inspect result_out for cancellation/exit status. Preserve
* their original contract: any contained terminal tree is transport
* success; only spawn/rejection or failed supervision is a wrapper error. */
return cause == MCP_SCAN_COMMAND_FAILURE || cause == MCP_SCAN_SUPERVISION_FAILURE ? -1 : 0;
}
/* Does `node`'s line range overlap any recorded hunk for `file`? Used to scope
+3
View File
@@ -23,6 +23,9 @@ void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_auto_index_count_test_hook_fn hook,
void *context);
#endif
void cbm_mcp_server_set_search_scan_command_for_test(cbm_mcp_server_t *srv, const char *command);
void cbm_mcp_server_set_search_scan_timeout_for_test(cbm_mcp_server_t *srv, uint64_t timeout_ms,
bool override_set);
/* Release only the constructor-created pristine in-memory store. Public
* cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use
+411 -15
View File
@@ -163,11 +163,14 @@ typedef struct {
int merge_base_calls;
} mcp_command_hook_probe_t;
#ifdef _WIN32
typedef struct {
cbm_mcp_server_t *server;
bool cancel_on_call;
bool cancel_accepted;
bool reject;
const char *fill_output_directory;
uint64_t delay_ms;
bool output_filled;
int calls;
char command[CBM_SZ_4K];
} mcp_search_command_probe_t;
@@ -176,7 +179,6 @@ typedef struct {
char path[512];
char *saved_cache;
} mcp_search_cache_t;
#endif
static bool mcp_quarantine_hook_probe(void *context, const char *step) {
mcp_quarantine_hook_probe_t *probe = context;
@@ -203,7 +205,6 @@ static bool mcp_command_hook_probe(void *context, const char *command) {
return true;
}
#ifdef _WIN32
static bool mcp_search_command_hook_probe(void *context, const char *command) {
mcp_search_command_probe_t *probe = context;
if (!probe || !command) {
@@ -211,10 +212,35 @@ static bool mcp_search_command_hook_probe(void *context, const char *command) {
}
probe->calls++;
snprintf(probe->command, sizeof(probe->command), "%s", command);
if (probe->delay_ms > 0) {
cbm_usleep(probe->delay_ms * 1000U);
}
if (probe->cancel_on_call && probe->server) {
probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->server);
}
return true;
if (probe->fill_output_directory) {
static const char prefix[] = ".mcp-command-";
cbm_dir_t *directory = cbm_opendir(probe->fill_output_directory);
cbm_dirent_t *entry;
while (directory && (entry = cbm_readdir(directory)) != NULL) {
if (strncmp(entry->name, prefix, sizeof(prefix) - SKIP_ONE) != 0) {
continue;
}
char path[CBM_SZ_2K];
snprintf(path, sizeof(path), "%s/%s", probe->fill_output_directory, entry->name);
FILE *output = cbm_fopen(path, "ab");
if (output) {
bool wrote = fputs("bounded-output-sentinel\n", output) >= 0;
bool closed = fclose(output) == 0;
probe->output_filled = wrote && closed;
}
break;
}
if (directory) {
cbm_closedir(directory);
}
}
return !probe->reject;
}
static bool mcp_search_cache_open(mcp_search_cache_t *cache, const char *prefix) {
@@ -240,7 +266,6 @@ static bool mcp_search_cache_close(mcp_search_cache_t *cache) {
cache->saved_cache = NULL;
return th_rmtree(cache->path) == 0;
}
#endif
typedef struct {
const char *name;
@@ -5070,8 +5095,7 @@ TEST(search_code_windows_prefilter_precedes_content_scan) {
#endif
}
TEST(search_code_windows_cancel_cleans_supervised_scan) {
#ifdef _WIN32
TEST(search_code_cancel_cleans_supervised_scan) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-cancel"));
@@ -5103,13 +5127,9 @@ TEST(search_code_windows_cancel_cleans_supervised_scan) {
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
PASS();
#else
SKIP_PLATFORM("supervised Select-String cancellation runs on Windows");
#endif
}
TEST(search_code_windows_output_limit_fails_closed_and_cleans_scan) {
#ifdef _WIN32
TEST(search_code_output_limit_fails_closed_and_cleans_scan) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-limit"));
@@ -5143,9 +5163,375 @@ TEST(search_code_windows_output_limit_fails_closed_and_cleans_scan) {
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
PASS();
}
TEST(search_code_scan_deadline_fails_closed_and_resets) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-deadline"));
int scratch_before = mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-");
ASSERT_TRUE(scratch_before >= 0);
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, true);
char *response =
cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
"\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(response);
ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(response, "request_timeout"));
ASSERT_NOT_NULL(strstr(response, "execution deadline"));
ASSERT_NOT_NULL(
strstr(response, "\"text\":\"search_code scan exceeded its execution deadline\""));
ASSERT_NOT_NULL(strstr(
response, "\"structuredContent\":{\"code\":\"request_timeout\",\"message\":\"search_code "
"scan exceeded its execution deadline\"}"));
ASSERT_NULL(strstr(response, "src/handler.go"));
ASSERT_NULL(strstr(response, "return nil"));
char logs[640];
snprintf(logs, sizeof(logs), "%s/logs", cache.path);
/* An immediate deadline can return before the log directory is created;
* both a missing directory (-1) and an empty one (0) prove no artifact. */
ASSERT_TRUE(mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") <= 0);
ASSERT_EQ(mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-"), scratch_before);
free(response);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, false);
response = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(response);
ASSERT_NULL(strstr(response, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(response, "src/handler.go"));
free(response);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
PASS();
}
TEST(search_code_scan_deadline_override_is_per_server) {
char tmp_a[512], src_a[768], vendor_a[768];
char tmp_b[512], src_b[768], vendor_b[768];
cbm_mcp_server_t *server_a = setup_prefilter_server(tmp_a, sizeof(tmp_a), src_a, sizeof(src_a),
vendor_a, sizeof(vendor_a));
cbm_mcp_server_t *server_b = setup_prefilter_server(tmp_b, sizeof(tmp_b), src_b, sizeof(src_b),
vendor_b, sizeof(vendor_b));
ASSERT_NOT_NULL(server_a);
ASSERT_NOT_NULL(server_b);
cbm_mcp_server_set_search_scan_timeout_for_test(server_a, 0, true);
char *timed_out = cbm_mcp_handle_tool(server_a, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
char *normal = cbm_mcp_handle_tool(server_b, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(timed_out);
ASSERT_NOT_NULL(normal);
ASSERT_NOT_NULL(strstr(timed_out, "request_timeout"));
ASSERT_NULL(strstr(normal, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(normal, "src/handler.go"));
free(timed_out);
free(normal);
cbm_mcp_server_free(server_a);
cbm_mcp_server_free(server_b);
cleanup_prefilter_dir(tmp_a, src_a, vendor_a);
cleanup_prefilter_dir(tmp_b, src_b, vendor_b);
PASS();
}
TEST(search_code_scan_setup_failures_respect_cause_precedence) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-setup-precedence"));
char cache_blocker[640];
snprintf(cache_blocker, sizeof(cache_blocker), "%s/not-a-directory", cache.path);
FILE *blocker = cbm_fopen(cache_blocker, "wb");
ASSERT_NOT_NULL(blocker);
ASSERT_EQ(fclose(blocker), 0);
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache_blocker, 1), 0);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, true);
/* Keep an outer request scope active so cancellation is latched before the
* nested tool call starts. It must beat both the zero deadline and the
* deliberately broken command-output directory. */
ASSERT_TRUE(cbm_mcp_server_request_scope_begin(srv));
ASSERT_TRUE(cbm_mcp_server_cancel_active(srv));
char *response = cbm_mcp_handle_tool(
srv, "search_code", "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"}");
bool cancellation_won = response && strstr(response, "cancelled") != NULL &&
strstr(response, "request_timeout") == NULL &&
strstr(response, "contained command") == NULL;
free(response);
cbm_mcp_server_request_scope_end(srv);
/* With cancellation cleared, the same broken setup must not hide the
* already-latched deadline. */
response = cbm_mcp_handle_tool(
srv, "search_code", "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"}");
bool deadline_won = response && strstr(response, "request_timeout") != NULL &&
strstr(response, "contained command") == NULL;
free(response);
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache.path, 1), 0);
ASSERT_EQ(cbm_unlink(cache_blocker), 0);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
ASSERT_TRUE(cancellation_won);
ASSERT_TRUE(deadline_won);
PASS();
}
TEST(search_code_scan_live_child_deadline_is_bounded_and_fails_closed) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-live-deadline"));
int scratch_before = mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-");
ASSERT_TRUE(scratch_before >= 0);
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
#ifdef _WIN32
const char *slow_command =
"echo deadline-partial-output & powershell.exe -NoProfile -Command \"Start-Sleep -Seconds "
"6\"";
#else
SKIP_PLATFORM("supervised Select-String output limit runs on Windows");
const char *slow_command = "printf 'deadline-partial-output\\n'; trap '' TERM; sleep 6";
#endif
cbm_mcp_server_set_search_scan_command_for_test(srv, slow_command);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 100, true);
uint64_t started = cbm_now_ms();
char *response = cbm_mcp_handle_tool(
srv, "search_code", "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"}");
uint64_t elapsed = cbm_now_ms() - started;
bool bounded = elapsed < 3000U;
bool timed_out = response && strstr(response, "request_timeout") != NULL &&
strstr(response, "\"isError\":true") != NULL;
bool partial_hidden = !response || strstr(response, "deadline-partial-output") == NULL;
char logs[640];
snprintf(logs, sizeof(logs), "%s/logs", cache.path);
int command_artifacts = mcp_count_directory_entries_with_prefix(logs, ".mcp-command-");
int scratch_after = mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-");
free(response);
cbm_mcp_server_set_search_scan_command_for_test(srv, NULL);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, false);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
ASSERT_TRUE(bounded);
ASSERT_TRUE(timed_out);
ASSERT_TRUE(partial_hidden);
ASSERT_EQ(command_artifacts, 0);
ASSERT_EQ(scratch_after, scratch_before);
PASS();
}
TEST(search_code_scan_cancellation_precedes_zero_deadline) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 0, true);
ASSERT_TRUE(cbm_mcp_server_request_scope_begin(srv));
ASSERT_TRUE(cbm_mcp_server_cancel_active(srv));
char *response = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(response);
ASSERT_NOT_NULL(strstr(response, "cancelled"));
ASSERT_NULL(strstr(response, "request_timeout"));
ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
free(response);
cbm_mcp_server_request_scope_end(srv);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
PASS();
}
TEST(search_code_scan_deadline_precedes_output_limit) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-precedence"));
char logs[640];
snprintf(logs, sizeof(logs), "%s/logs", cache.path);
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
mcp_search_command_probe_t probe = {.fill_output_directory = logs, .delay_ms = 1100};
cbm_mcp_server_set_command_test_hook(srv, mcp_search_command_hook_probe, &probe);
cbm_mcp_server_set_search_scan_timeout_for_test(srv, 1000, true);
cbm_mcp_server_set_search_output_limit_for_test(srv, 1);
char *response = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(response);
ASSERT_TRUE(probe.output_filled);
ASSERT_NOT_NULL(strstr(response, "request_timeout"));
ASSERT_NULL(strstr(response, "output exceeded"));
ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
ASSERT_EQ(mcp_count_directory_entries_with_prefix(logs, ".mcp-command-"), 0);
free(response);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
PASS();
}
TEST(search_code_scan_hook_rejection_is_contained_and_cleans_up) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-reject"));
int scratch_before = mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-");
ASSERT_TRUE(scratch_before >= 0);
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
mcp_search_command_probe_t probe = {.reject = true};
cbm_mcp_server_set_command_test_hook(srv, mcp_search_command_hook_probe, &probe);
char *response = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-"
"search\",\"file_pattern\":\"*.go\"}");
ASSERT_NOT_NULL(response);
ASSERT_EQ(probe.calls, 1);
ASSERT_NOT_NULL(strstr(response, "contained command could not complete"));
ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
ASSERT_NULL(strstr(response, "src/handler.go"));
char logs[640];
snprintf(logs, sizeof(logs), "%s/logs", cache.path);
ASSERT_EQ(mcp_count_directory_entries_with_prefix(logs, ".mcp-command-"), 0);
ASSERT_EQ(mcp_count_directory_entries_with_prefix(cbm_tmpdir(), "cbm-search-"), scratch_before);
free(response);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(mcp_search_cache_close(&cache));
PASS();
}
TEST(search_code_scoped_exit_one_is_not_no_match) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
#ifdef _WIN32
cbm_mcp_server_set_search_scan_command_for_test(srv, "exit /b 1");
#else
cbm_mcp_server_set_search_scan_command_for_test(srv, "exit 1");
#endif
char *response = cbm_mcp_handle_tool(
srv, "search_code", "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"}");
bool failed_closed = response && strstr(response, "contained command could not complete") &&
strstr(response, "\"isError\":true") &&
!strstr(response, "total_grep_matches: 0");
free(response);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
ASSERT_TRUE(failed_closed);
PASS();
}
TEST(search_code_no_match_is_empty_for_direct_and_scoped_routes) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *scoped = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(scoped);
char *scoped_response = cbm_mcp_handle_tool(
scoped, "search_code",
"{\"pattern\":\"DefinitelyAbsentSymbol\",\"project\":\"prefilter-search\"}");
ASSERT_NOT_NULL(scoped_response);
ASSERT_NULL(strstr(scoped_response, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(scoped_response, "total_grep_matches: 0"));
cbm_mcp_server_t *direct = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(direct);
cbm_store_t *store = cbm_mcp_server_store(direct);
ASSERT_NOT_NULL(store);
cbm_mcp_server_set_project(direct, "direct-search");
ASSERT_EQ(cbm_store_upsert_project(store, "direct-search", tmp), CBM_STORE_OK);
char *direct_response = cbm_mcp_handle_tool(
direct, "search_code",
"{\"pattern\":\"DefinitelyAbsentSymbol\",\"project\":\"direct-search\"}");
ASSERT_NOT_NULL(direct_response);
ASSERT_NULL(strstr(direct_response, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(direct_response, "total_grep_matches: 0"));
free(scoped_response);
free(direct_response);
cbm_mcp_server_free(scoped);
cbm_mcp_server_free(direct);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
PASS();
}
/* A store may contain an indexed path that is non-regular or no longer exists.
* Neither is a content-scan operand. Scoped search must skip both while
* preserving matches from regular files; actual command failures remain
* contained. */
TEST(search_code_scoped_scan_skips_non_regular_indexed_paths) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
char indexed_dir[768];
snprintf(indexed_dir, sizeof(indexed_dir), "%s/indexed-dir", tmp);
ASSERT_EQ(cbm_mkdir(indexed_dir), 0);
cbm_store_t *store = cbm_mcp_server_store(srv);
cbm_node_t directory_node = {.project = "prefilter-search",
.label = "File",
.name = "indexed-dir",
.qualified_name = "prefilter-search.indexed-dir",
.file_path = "indexed-dir",
.start_line = 1,
.end_line = 1};
ASSERT_GT(cbm_store_upsert_node(store, &directory_node), 0);
cbm_node_t missing_node = {.project = "prefilter-search",
.label = "File",
.name = "missing.go",
.qualified_name = "prefilter-search.missing.go",
.file_path = "missing.go",
.start_line = 1,
.end_line = 1};
ASSERT_GT(cbm_store_upsert_node(store, &missing_node), 0);
char *response = cbm_mcp_handle_tool(
srv, "search_code", "{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\"}");
ASSERT_NOT_NULL(response);
ASSERT_NULL(strstr(response, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(response, "src/handler.go"));
free(response);
cbm_mcp_server_free(srv);
ASSERT_EQ(cbm_rmdir(indexed_dir), 0);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
PASS();
}
/* Windows raw scans must pin the PowerShell pipe to UTF-8: PS 5.1 otherwise
@@ -12755,8 +13141,18 @@ SUITE(mcp) {
RUN_TEST(search_code_path_filter_matches_nothing);
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
RUN_TEST(search_code_windows_prefilter_precedes_content_scan);
RUN_TEST(search_code_windows_cancel_cleans_supervised_scan);
RUN_TEST(search_code_windows_output_limit_fails_closed_and_cleans_scan);
RUN_TEST(search_code_cancel_cleans_supervised_scan);
RUN_TEST(search_code_output_limit_fails_closed_and_cleans_scan);
RUN_TEST(search_code_scan_deadline_fails_closed_and_resets);
RUN_TEST(search_code_scan_deadline_override_is_per_server);
RUN_TEST(search_code_scan_setup_failures_respect_cause_precedence);
RUN_TEST(search_code_scan_live_child_deadline_is_bounded_and_fails_closed);
RUN_TEST(search_code_scan_cancellation_precedes_zero_deadline);
RUN_TEST(search_code_scan_deadline_precedes_output_limit);
RUN_TEST(search_code_scan_hook_rejection_is_contained_and_cleans_up);
RUN_TEST(search_code_scoped_exit_one_is_not_no_match);
RUN_TEST(search_code_no_match_is_empty_for_direct_and_scoped_routes);
RUN_TEST(search_code_scoped_scan_skips_non_regular_indexed_paths);
RUN_TEST(search_code_windows_scan_pins_utf8_output);
RUN_TEST(search_code_invalid_regex_errors_issue283);
RUN_TEST(search_code_literal_pipe_warns_issue282);
+140
View File
@@ -22,6 +22,8 @@
#include <fcntl.h>
#include <signal.h>
#include <unistd.h>
#else
#include <windows.h>
#endif
/* ── Layer 1: pure classifier (all platforms) ─────────────────────────────── */
@@ -558,6 +560,143 @@ TEST(subprocess_quiet_timeout_kills_ignoring_tree) {
#endif
}
TEST(subprocess_windows_job_object_cancellation_quiesces_descendant_tree) {
#ifndef _WIN32
SKIP_PLATFORM("native Windows Job Object descendant-tree probe");
#else
char temp_dir[MAX_PATH];
DWORD temp_length = GetTempPathA((DWORD)sizeof(temp_dir), temp_dir);
ASSERT_TRUE(temp_length > 0 && temp_length < sizeof(temp_dir));
char pid_path[MAX_PATH];
ASSERT_TRUE(GetTempFileNameA(temp_dir, "cbm", 0, pid_path) != 0);
ASSERT_TRUE(DeleteFileA(pid_path));
char system_directory[MAX_PATH];
UINT system_length = GetSystemDirectoryA(system_directory, (UINT)sizeof(system_directory));
ASSERT_TRUE(system_length > 0 && system_length < sizeof(system_directory));
char powershell_path[MAX_PATH];
ASSERT_TRUE(snprintf(powershell_path, sizeof(powershell_path),
"%s\\WindowsPowerShell\\v1.0\\powershell.exe", system_directory) > 0);
char script[4096];
int script_length = snprintf(
script, sizeof(script),
"$child=Start-Process powershell.exe "
"-ArgumentList '-NoProfile','-Command','while ($true) { Start-Sleep -Milliseconds 100 }' "
"-WindowStyle Hidden -PassThru; Set-Content -Encoding ASCII -LiteralPath '%s' "
"-Value ($PID.ToString() + ' ' + $child.Id.ToString()); "
"while ($true) { Start-Sleep -Milliseconds 100 }",
pid_path);
ASSERT_TRUE(script_length > 0 && (size_t)script_length < sizeof(script));
const char *argv[] = {powershell_path, "-NoProfile", "-Command", script, NULL};
cbm_proc_opts_t opts = {0};
opts.bin = powershell_path;
opts.argv = argv;
opts.cancel_grace_ms = 100;
cbm_subprocess_t *process = NULL;
ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0);
ASSERT_NOT_NULL(process);
DWORD root_pid = 0;
DWORD grandchild_pid = 0;
uint64_t ready_deadline = cbm_now_ms() + 3000U;
bool ready = false;
while (cbm_now_ms() < ready_deadline) {
FILE *pid_file = fopen(pid_path, "r");
if (pid_file) {
unsigned long root_value = 0;
unsigned long grandchild_value = 0;
int fields = fscanf(pid_file, "%lu %lu", &root_value, &grandchild_value);
(void)fclose(pid_file);
if (fields == 2 && root_value > 0 && grandchild_value > 0) {
root_pid = (DWORD)root_value;
grandchild_pid = (DWORD)grandchild_value;
ready = true;
break;
}
}
cbm_proc_result_t ignored;
if (cbm_subprocess_poll(process, &ignored) != CBM_PROC_POLL_RUNNING) {
break;
}
Sleep(10);
}
HANDLE root_handle =
ready ? OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, root_pid) : NULL;
HANDLE grandchild_handle =
ready ? OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, grandchild_pid) : NULL;
/* Request cancellation even if the PID probe failed so a failing test never
* leaves its supervised tree running in the shared Windows VM. */
bool cancelled = cbm_subprocess_request_cancel(process);
cbm_proc_result_t result = {0};
bool terminal = false;
uint64_t terminal_deadline = cbm_now_ms() + 4000U;
while (cbm_now_ms() < terminal_deadline) {
cbm_proc_poll_t state = cbm_subprocess_poll(process, &result);
if (state == CBM_PROC_POLL_TERMINAL) {
terminal = true;
break;
}
if (state == CBM_PROC_POLL_ERROR) {
break;
}
Sleep(10);
}
/* If the behavior under test regresses, terminate both known Job members
* directly and give the supervisor one bounded drain interval. The test
* still fails on the original `terminal` verdict, but it cannot strand its
* infinite root/grandchild or retain a terminal subprocess/Job handle. */
bool cleanup_terminal = terminal;
if (!cleanup_terminal) {
if (grandchild_handle) {
(void)TerminateProcess(grandchild_handle, 1);
}
if (root_handle) {
(void)TerminateProcess(root_handle, 1);
}
(void)cbm_subprocess_request_cancel(process);
uint64_t cleanup_deadline = cbm_now_ms() + 2000U;
while (cbm_now_ms() < cleanup_deadline) {
cbm_proc_result_t cleanup_result;
cbm_proc_poll_t state = cbm_subprocess_poll(process, &cleanup_result);
if (state == CBM_PROC_POLL_TERMINAL) {
cleanup_terminal = true;
break;
}
if (state == CBM_PROC_POLL_ERROR) {
break;
}
Sleep(10);
}
}
bool root_gone = root_handle && WaitForSingleObject(root_handle, 2000) == WAIT_OBJECT_0;
bool grandchild_gone =
grandchild_handle && WaitForSingleObject(grandchild_handle, 2000) == WAIT_OBJECT_0;
if (root_handle) {
CloseHandle(root_handle);
}
if (grandchild_handle) {
CloseHandle(grandchild_handle);
}
if (cleanup_terminal) {
cbm_subprocess_destroy(process);
}
(void)DeleteFileA(pid_path);
ASSERT_TRUE(ready);
ASSERT_TRUE(cancelled);
ASSERT_TRUE(terminal);
ASSERT_TRUE(result.cancellation_requested);
ASSERT_TRUE(result.tree_quiesced);
ASSERT_FALSE(result.supervision_failed);
ASSERT_TRUE(root_gone);
ASSERT_TRUE(grandchild_gone);
PASS();
#endif
}
TEST(subprocess_cancel_grace_is_hard_capped) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX process-group grace cap probe; native Windows coverage pending");
@@ -1040,6 +1179,7 @@ SUITE(subprocess) {
RUN_TEST(subprocess_natural_completion_is_cached_across_polls);
RUN_TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree);
RUN_TEST(subprocess_quiet_timeout_kills_ignoring_tree);
RUN_TEST(subprocess_windows_job_object_cancellation_quiesces_descendant_tree);
RUN_TEST(subprocess_cancel_grace_is_hard_capped);
RUN_TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless);
RUN_TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification);