diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 00bad272f..5e64fabdf 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -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 "/" 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 /-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 diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index 61b9383c9..8355bb390 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -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 diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 15c00a6ac..de36cb6bd 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -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); diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index 30fd1537d..c9932bf2d 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -22,6 +22,8 @@ #include #include #include +#else +#include #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);