Skip to content

Commit 16552be

Browse files
test(mcp): add rapid-init integration test for poll/getline buffering fix
1 parent b8b2dd3 commit 16552be

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

scripts/test_mcp_rapid_init.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env python3
2+
"""Integration test for the poll/getline FILE* buffering fix.
3+
4+
Spawns the MCP server binary, sends initialize + notifications/initialized +
5+
tools/list all at once (no delays), and asserts that the tools/list response
6+
arrives within 5 seconds.
7+
8+
Usage:
9+
python3 scripts/test_mcp_rapid_init.py [/path/to/binary]
10+
11+
Exit codes:
12+
0 - PASS
13+
1 - FAIL
14+
"""
15+
16+
import subprocess
17+
import sys
18+
import os
19+
20+
TIMEOUT_S = 5
21+
22+
MESSAGES = (
23+
b'{"jsonrpc":"2.0","id":1,"method":"initialize",'
24+
b'"params":{"protocolVersion":"2025-11-25","capabilities":{}}}\n'
25+
b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n'
26+
b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n'
27+
)
28+
29+
30+
def main():
31+
if len(sys.argv) >= 2:
32+
binary = sys.argv[1]
33+
else:
34+
# Default: look for build artifact relative to this script's directory
35+
script_dir = os.path.dirname(os.path.abspath(__file__))
36+
repo_root = os.path.dirname(script_dir)
37+
binary = os.path.join(repo_root, "build", "c", "codebase-memory-mcp")
38+
39+
if not os.path.isfile(binary):
40+
print(f"FAIL: binary not found at {binary}")
41+
sys.exit(1)
42+
43+
if not os.access(binary, os.X_OK):
44+
print(f"FAIL: binary not executable: {binary}")
45+
sys.exit(1)
46+
47+
proc = subprocess.Popen(
48+
[binary],
49+
stdin=subprocess.PIPE,
50+
stdout=subprocess.PIPE,
51+
stderr=subprocess.DEVNULL,
52+
)
53+
54+
try:
55+
# Write all 3 messages in one call and close stdin to signal EOF
56+
stdout_data, _ = proc.communicate(input=MESSAGES, timeout=TIMEOUT_S)
57+
except subprocess.TimeoutExpired:
58+
proc.kill()
59+
proc.wait()
60+
print(
61+
f"FAIL: server did not respond within {TIMEOUT_S}s "
62+
f"(poll/getline buffering bug not fixed)"
63+
)
64+
sys.exit(1)
65+
66+
output = stdout_data.decode("utf-8", errors="replace")
67+
68+
if "tools" not in output:
69+
print("FAIL: tools/list response not found in server output")
70+
print(f"Server output was:\n{output!r}")
71+
sys.exit(1)
72+
73+
print("PASS")
74+
sys.exit(0)
75+
76+
77+
if __name__ == "__main__":
78+
main()

tests/test_mcp.c

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,78 @@ TEST(snippet_include_neighbors_enabled) {
12151215
PASS();
12161216
}
12171217

1218+
/* ══════════════════════════════════════════════════════════════════
1219+
* POLL/GETLINE FILE* BUFFERING FIX
1220+
* ══════════════════════════════════════════════════════════════════ */
1221+
1222+
#ifndef _WIN32
1223+
#include <unistd.h>
1224+
#include <signal.h>
1225+
1226+
/* Signal handler used by alarm() to abort the test if it hangs */
1227+
static void alarm_handler(int sig) {
1228+
(void)sig;
1229+
/* Writing to stderr is async-signal-safe */
1230+
const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n";
1231+
write(STDERR_FILENO, msg, sizeof(msg) - 1);
1232+
_exit(1);
1233+
}
1234+
1235+
TEST(mcp_server_run_rapid_messages) {
1236+
/* Simulate a client sending initialize + notifications/initialized +
1237+
* tools/list all at once (no delays), which exercises the FILE*
1238+
* buffering fix: the first getline() over-reads kernel data into the
1239+
* libc buffer; without the fix, subsequent poll() calls block for 60s.
1240+
*
1241+
* We use alarm(5) to abort the test process if the server hangs. */
1242+
int fds[2];
1243+
ASSERT_EQ(pipe(fds), 0);
1244+
1245+
/* Write all 3 messages to the write end in one shot */
1246+
const char *msgs =
1247+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
1248+
"\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n"
1249+
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n"
1250+
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n";
1251+
ssize_t written = write(fds[1], msgs, strlen(msgs));
1252+
ASSERT_TRUE(written > 0);
1253+
close(fds[1]); /* EOF signals end of input to the server */
1254+
1255+
FILE *in_fp = fdopen(fds[0], "r");
1256+
ASSERT_NOT_NULL(in_fp);
1257+
1258+
FILE *out_fp = tmpfile();
1259+
ASSERT_NOT_NULL(out_fp);
1260+
1261+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
1262+
ASSERT_NOT_NULL(srv);
1263+
1264+
/* Install alarm to fail the test if cbm_mcp_server_run blocks */
1265+
signal(SIGALRM, alarm_handler);
1266+
alarm(5);
1267+
1268+
int rc = cbm_mcp_server_run(srv, in_fp, out_fp);
1269+
1270+
alarm(0); /* cancel alarm */
1271+
signal(SIGALRM, SIG_DFL);
1272+
1273+
ASSERT_EQ(rc, 0);
1274+
1275+
/* Verify that tools/list response is present in output */
1276+
rewind(out_fp);
1277+
char buf[4096] = {0};
1278+
size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp);
1279+
ASSERT_TRUE(nread > 0);
1280+
ASSERT_NOT_NULL(strstr(buf, "tools"));
1281+
1282+
cbm_mcp_server_free(srv);
1283+
fclose(out_fp);
1284+
/* in_fp already EOF; fclose cleans up */
1285+
fclose(in_fp);
1286+
PASS();
1287+
}
1288+
#endif /* !_WIN32 */
1289+
12181290
/* ══════════════════════════════════════════════════════════════════
12191291
* SUITE
12201292
* ══════════════════════════════════════════════════════════════════ */
@@ -1287,6 +1359,11 @@ SUITE(mcp) {
12871359
RUN_TEST(parse_file_uri_windows);
12881360
RUN_TEST(parse_file_uri_invalid);
12891361

1362+
/* Poll/getline FILE* buffering fix */
1363+
#ifndef _WIN32
1364+
RUN_TEST(mcp_server_run_rapid_messages);
1365+
#endif
1366+
12901367
/* Snippet resolution (port of snippet_test.go) */
12911368
RUN_TEST(snippet_exact_qn);
12921369
RUN_TEST(snippet_qn_suffix);

0 commit comments

Comments
 (0)