mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-03 08:03:38 +08:00
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:
+270
-76
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user