diff --git a/scripts/test_mcp_rapid_init.py b/scripts/test_mcp_rapid_init.py new file mode 100755 index 000000000..d2c5f1434 --- /dev/null +++ b/scripts/test_mcp_rapid_init.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Integration test for the poll/getline FILE* buffering fix. + +Spawns the MCP server binary, sends initialize + notifications/initialized + +tools/list all at once (no delays), and asserts that the tools/list response +arrives within 5 seconds. + +Usage: + python3 scripts/test_mcp_rapid_init.py [/path/to/binary] + +Exit codes: + 0 - PASS + 1 - FAIL +""" + +import subprocess +import sys +import os + +TIMEOUT_S = 5 + +MESSAGES = ( + b'{"jsonrpc":"2.0","id":1,"method":"initialize",' + b'"params":{"protocolVersion":"2025-11-25","capabilities":{}}}\n' + b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n' + b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' +) + + +def main(): + if len(sys.argv) >= 2: + binary = sys.argv[1] + else: + # Default: look for build artifact relative to this script's directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.dirname(script_dir) + binary = os.path.join(repo_root, "build", "c", "codebase-memory-mcp") + + if not os.path.isfile(binary): + print(f"FAIL: binary not found at {binary}") + sys.exit(1) + + if not os.access(binary, os.X_OK): + print(f"FAIL: binary not executable: {binary}") + sys.exit(1) + + proc = subprocess.Popen( + [binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + ) + + try: + # Write all 3 messages in one call and close stdin to signal EOF + stdout_data, _ = proc.communicate(input=MESSAGES, timeout=TIMEOUT_S) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + print( + f"FAIL: server did not respond within {TIMEOUT_S}s " + f"(poll/getline buffering bug not fixed)" + ) + sys.exit(1) + + output = stdout_data.decode("utf-8", errors="replace") + + if "tools" not in output: + print("FAIL: tools/list response not found in server output") + print(f"Server output was:\n{output!r}") + sys.exit(1) + + print("PASS") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/test_mcp.c b/tests/test_mcp.c index a63412712..eb9f34601 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1215,6 +1215,78 @@ TEST(snippet_include_neighbors_enabled) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * POLL/GETLINE FILE* BUFFERING FIX + * ══════════════════════════════════════════════════════════════════ */ + +#ifndef _WIN32 +#include +#include + +/* Signal handler used by alarm() to abort the test if it hangs */ +static void alarm_handler(int sig) { + (void)sig; + /* Writing to stderr is async-signal-safe */ + const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n"; + write(STDERR_FILENO, msg, sizeof(msg) - 1); + _exit(1); +} + +TEST(mcp_server_run_rapid_messages) { + /* Simulate a client sending initialize + notifications/initialized + + * tools/list all at once (no delays), which exercises the FILE* + * buffering fix: the first getline() over-reads kernel data into the + * libc buffer; without the fix, subsequent poll() calls block for 60s. + * + * We use alarm(5) to abort the test process if the server hangs. */ + int fds[2]; + ASSERT_EQ(pipe(fds), 0); + + /* Write all 3 messages to the write end in one shot */ + const char *msgs = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + "\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n" + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n"; + ssize_t written = write(fds[1], msgs, strlen(msgs)); + ASSERT_TRUE(written > 0); + close(fds[1]); /* EOF signals end of input to the server */ + + FILE *in_fp = fdopen(fds[0], "r"); + ASSERT_NOT_NULL(in_fp); + + FILE *out_fp = tmpfile(); + ASSERT_NOT_NULL(out_fp); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + /* Install alarm to fail the test if cbm_mcp_server_run blocks */ + signal(SIGALRM, alarm_handler); + alarm(5); + + int rc = cbm_mcp_server_run(srv, in_fp, out_fp); + + alarm(0); /* cancel alarm */ + signal(SIGALRM, SIG_DFL); + + ASSERT_EQ(rc, 0); + + /* Verify that tools/list response is present in output */ + rewind(out_fp); + char buf[4096] = {0}; + size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp); + ASSERT_TRUE(nread > 0); + ASSERT_NOT_NULL(strstr(buf, "tools")); + + cbm_mcp_server_free(srv); + fclose(out_fp); + /* in_fp already EOF; fclose cleans up */ + fclose(in_fp); + PASS(); +} +#endif /* !_WIN32 */ + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1287,6 +1359,11 @@ SUITE(mcp) { RUN_TEST(parse_file_uri_windows); RUN_TEST(parse_file_uri_invalid); + /* Poll/getline FILE* buffering fix */ +#ifndef _WIN32 + RUN_TEST(mcp_server_run_rapid_messages); +#endif + /* Snippet resolution (port of snippet_test.go) */ RUN_TEST(snippet_exact_qn); RUN_TEST(snippet_qn_suffix);