From 7a93a383aaaf3b51ea6d03ce56f65db857e823ae Mon Sep 17 00:00:00 2001 From: Flipper Date: Sun, 5 Jul 2026 10:07:00 +0200 Subject: [PATCH] test(win): full UI-mode hang repro harness for #798 follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap flagged in the distill review (test_security.c: "NOT proven here: the full UI repro — listening socket + MSYS2 handle walk under a single-threaded server"). Test-only; no production code changes. Guard 1 — deterministic socket-isolation probe (any Windows, RED-able): popen_isolates_listening_socket (tests/test_security.c) opens a real inheritable AFD/listening socket — the exact handle class that deadlocked git in #798 — then spawns THIS binary through cbm_popen (a cmd.exe grandchild, git's spawn shape) in a new __cbm_sockprobe re-exec mode (tests/test_main.c). The child reports via exit code whether that socket handle is live in its address space: isolated spawn -> getsockopt fails -> exit 0 (GREEN); raw-_popen regression leaks it transitively through cmd.exe -> getsockopt succeeds -> exit 42 (RED). Verified RED with a local spawn-inherits-all revert, GREEN with the isolated spawn. Guard 2 — end-to-end liveness under live UI sockets (tests/test_httpd.c): - ui_server_list_projects_responds_under_watchdog: POST /rpc list_projects to the running single-threaded UI server with a client SO_RCVTIMEO watchdog; a wedge -> no 200 -> hard FAIL, never an infinite CI hang. - git_context_resolve_no_hang_under_live_ui_sockets: while the UI server holds live listening/AFD handles in-process, run cbm_git_context_resolve (the exact path list_projects takes) on a worker thread under a WaitForSingleObject(30s) watchdog; a hang -> FAIL. SKIPs where git cannot init a repo via system(). All three run in the existing security/httpd suites on the test-windows CI leg; no new build wiring. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_018g9f9jr5S46BfKaDvDykBp Signed-off-by: Flipper --- tests/test_httpd.c | 126 ++++++++++++++++++++++++++++++++++++++++++ tests/test_main.c | 43 ++++++++++++++ tests/test_security.c | 60 +++++++++++++++++++- 3 files changed, 228 insertions(+), 1 deletion(-) diff --git a/tests/test_httpd.c b/tests/test_httpd.c index d0173d2cc..d7cb25742 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -16,6 +16,7 @@ #include "../src/foundation/log.h" #include "../src/foundation/platform.h" #include "../src/cli/cli.h" +#include "../src/git/git_context.h" /* #798 follow-up: live-socket git-resolve repro */ #include "../src/ui/http_server.h" #include "test_framework.h" #include "test_helpers.h" @@ -34,6 +35,7 @@ #ifdef _WIN32 #include #include +#include /* #798 follow-up: CreateThread/WaitForSingleObject watchdog */ typedef SOCKET th_sock_t; #define th_sock_close closesocket #define TH_SOCK_BAD INVALID_SOCKET @@ -41,6 +43,7 @@ typedef SOCKET th_sock_t; #include #include #include +#include /* struct timeval for the SO_RCVTIMEO watchdog (#798 follow-up) */ #include typedef int th_sock_t; #define th_sock_close close @@ -962,6 +965,126 @@ TEST(repo_info_strips_credentials_from_remote) { PASS(); } +/* ── #798 follow-up: full UI-mode hang repro (live sockets) ───── */ + +/* Like th_http but arms a client-side receive-timeout watchdog. If the + * single-threaded server wedges, recv() returns instead of blocking forever, so + * the test FAILs deterministically rather than hanging CI. 0 on connect/timeout. */ +static int th_http_deadline(int port, const char *request, char *resp, size_t respsz, + int timeout_ms) { + th_sock_t s = th_connect(port); + if (s == TH_SOCK_BAD) + return 0; +#ifdef _WIN32 + DWORD tv = (DWORD)timeout_ms; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (const char *)&tv, sizeof(tv)); +#else + struct timeval tv; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = (timeout_ms % 1000) * 1000; + setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); +#endif + if (th_send_all(s, request, strlen(request)) != 0) { + th_sock_close(s); + return 0; + } + int n = th_recv_until_close(s, resp, respsz); + th_sock_close(s); + return n; +} + +/* #798 was a single-threaded-server wedge: list_projects never returned and the + * whole UI stopped answering. Assert the running server answers list_projects + * within a hard deadline while it holds live listening sockets. The client + * receive-timeout is the watchdog: a wedge → no 200 → FAIL, never a CI hang. */ +TEST(ui_server_list_projects_responds_under_watchdog) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + const char *body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}"; + char req[512]; + snprintf(req, sizeof(req), + "POST /rpc HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n\r\n%s", + (int)strlen(body), body); + char resp[8192]; + int n = th_http_deadline(cbm_http_server_port(ts.srv), req, resp, sizeof(resp), 15000); + th_server_stop(&ts); + ASSERT_GT(n, 0); /* a response arrived before the watchdog fired */ + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"jsonrpc\"")); + PASS(); +} + +#ifdef _WIN32 +typedef struct { + char path[512]; + int resolved_ok; +} th_gitctx_probe_t; + +static DWORD WINAPI th_gitctx_probe_thread(LPVOID arg) { + th_gitctx_probe_t *p = (th_gitctx_probe_t *)arg; + cbm_git_context_t ctx; + memset(&ctx, 0, sizeof(ctx)); + int rc = cbm_git_context_resolve(p->path, &ctx); + p->resolved_ok = (rc == 0 && ctx.is_git) ? 1 : 0; + cbm_git_context_free(&ctx); + return 0; +} +#endif + +/* The load-bearing end-to-end repro of #798: while the single-threaded UI server + * holds LIVE listening/AFD socket handles in this process, cbm_git_context_resolve + * — the exact path list_projects runs (add_git_context_json → resolve → + * cbm_popen(git)) — must not hang. Under a raw-_popen regression git inherits + * those sockets and its MSYS2 runtime deadlocks in NtQueryObject; the watchdog + * turns that into a hard FAIL instead of an infinite hang. */ +TEST(git_context_resolve_no_hang_under_live_ui_sockets) { +#ifndef _WIN32 + SKIP_PLATFORM("Windows-only: #798 UI listening-socket handle inheritance"); +#else + char *tmp = th_mktempdir("cbm_798repro"); + if (!tmp) + FAIL("th_mktempdir returned NULL"); + + char cmd[1024]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" init -q && git -C \"%s\" -c user.email=t@t -c user.name=t " + "commit -q --allow-empty -m init", + tmp, tmp); + if (system(cmd) != 0) { + th_rmtree(tmp); + SKIP_PLATFORM("git not available to init a repo"); + } + + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + + th_gitctx_probe_t *probe = (th_gitctx_probe_t *)calloc(1, sizeof(*probe)); + ASSERT_NOT_NULL(probe); + snprintf(probe->path, sizeof(probe->path), "%s", tmp); + + HANDLE h = CreateThread(NULL, 0, th_gitctx_probe_thread, probe, 0, NULL); + ASSERT_NOT_NULL(h); + DWORD w = WaitForSingleObject(h, 30000); + if (w != WAIT_OBJECT_0) { + /* Wedged on the inherited-socket NtQueryObject walk. Deliberately leak + * the heap probe + thread (a late wake must not touch freed memory); + * process exit reaps them. Fail loudly rather than hang CI. */ + th_server_stop(&ts); + FAIL("cbm_git_context_resolve hung under live UI sockets (#798 regression)"); + } + CloseHandle(h); + th_server_stop(&ts); + int ok = probe->resolved_ok; + free(probe); + th_rmtree(tmp); + ASSERT_EQ(ok, 1); + PASS(); +#endif +} + /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { @@ -1010,4 +1133,7 @@ SUITE(httpd) { RUN_TEST(ui_server_slow_request_hits_deadline); RUN_TEST(ui_server_access_log_redacts_query); RUN_TEST(ui_server_stop_joins_cleanly); + /* #798 follow-up: full UI-mode hang repro under live sockets */ + RUN_TEST(ui_server_list_projects_responds_under_watchdog); + RUN_TEST(git_context_resolve_no_hang_under_live_ui_sockets); } diff --git a/tests/test_main.c b/tests/test_main.c index e2e65daf7..aeae04924 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -16,9 +16,13 @@ int tf_skip_count = 0; #include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ #include #include +#include #include #include #include +#ifdef _WIN32 +#include /* #798 follow-up: socket-isolation re-exec probe */ +#endif /* #832 guard support: when the index supervisor spawns THIS binary as * ` cli --index-worker index_repository --response-out ` @@ -77,6 +81,38 @@ static int tf_maybe_run_index_worker(int argc, char **argv) { return 0; } +/* #798 follow-up: socket-isolation probe. The parent test + * (popen_isolates_listening_socket, test_security.c) spawns THIS binary through + * cbm_popen — the same cmd.exe-grandchild path git takes — passing the numeric + * value of an inheritable listening-socket handle. If cbm_popen correctly + * isolates handles, that socket is NOT present in this child and getsockopt + * fails; a regression to raw _popen leaks it (bInheritHandles=TRUE propagates it + * transitively through cmd.exe) and getsockopt succeeds. We report via exit code + * so the verdict survives `cmd.exe /c` (proven by popen_isolated_propagates_exit_code). + * Returns an exit code (>=0) when it handled a probe invocation, else -1. */ +static int tf_maybe_run_socket_probe(int argc, char **argv) { +#ifdef _WIN32 + if (argc < 3 || strcmp(argv[1], "__cbm_sockprobe") != 0) { + return -1; + } + WSADATA wsa; + if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { + return 0; /* no winsock in child ⇒ cannot observe a socket ⇒ not leaked */ + } + unsigned long long hv = strtoull(argv[2], NULL, 10); + SOCKET s = (SOCKET)(uintptr_t)hv; + int type = 0; + int len = (int)sizeof(type); + int rc = getsockopt(s, SOL_SOCKET, SO_TYPE, (char *)&type, &len); + /* rc==0 ⇒ the handle is a live socket in THIS child ⇒ it was inherited. */ + return rc == 0 ? 42 : 0; +#else + (void)argc; + (void)argv; + return -1; +#endif +} + static int g_suite_argc = 0; static char **g_suite_argv = NULL; @@ -201,6 +237,13 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { + /* #798 follow-up: if spawned as the socket-isolation probe, report whether an + * inheritable socket handle crossed into this child and exit before any suite. */ + int probe_rc = tf_maybe_run_socket_probe(argc, argv); + if (probe_rc >= 0) { + return probe_rc; + } + /* #832: if spawned as a supervised index worker, do the real work and exit * before any suite runs (see tf_maybe_run_index_worker). */ int worker_rc = tf_maybe_run_index_worker(argc, argv); diff --git a/tests/test_security.c b/tests/test_security.c index 5e05be2a4..d849d7bd1 100644 --- a/tests/test_security.c +++ b/tests/test_security.c @@ -15,8 +15,11 @@ #ifdef _WIN32 #include "../src/foundation/compat_fs_internal.h" #include "../src/foundation/win_utf8.h" -#include +#include /* #798 follow-up: listening-socket isolation guard */ +#include +#include #include +#include #endif #include @@ -558,6 +561,60 @@ TEST(popen_isolated_propagates_exit_code) { PASS(); } +/* #798 follow-up (the full-repro gap flagged above): prove the EXACT handle class + * that deadlocked git — an inheritable AFD/listening-socket handle, the kind the + * UI HTTP server holds — does NOT cross into the cbm_popen child. Unlike the + * git-version round-trip, this is deterministic on ANY Windows and does not depend + * on the MSYS2 git build reproducing the NtQueryObject hang. + * + * We open a real listening socket, mark it inheritable, then spawn THIS test + * binary through cbm_popen (a cmd.exe grandchild — exactly git's spawn shape) in + * `__cbm_sockprobe` mode, passing the socket's numeric handle value. The child + * reports via exit code whether that handle is a live socket in its address space: + * - isolated spawn (the fix): cmd.exe inherits only {pipe, NUL}, the socket is + * absent, getsockopt fails → child exit 0 → GREEN. + * - raw _popen (regression): bInheritHandles=TRUE leaks the socket transitively + * through cmd.exe into the child, getsockopt succeeds → child exit 42 → RED. + * Verified RED with a local _popen revert, GREEN with the isolated spawn. */ +TEST(popen_isolates_listening_socket) { + WSADATA wsa; + ASSERT_EQ(WSAStartup(MAKEWORD(2, 2), &wsa), 0); + + SOCKET ls = socket(AF_INET, SOCK_STREAM, 0); + ASSERT(ls != INVALID_SOCKET); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(0x7F000001); /* 127.0.0.1 */ + addr.sin_port = 0; /* ephemeral */ + ASSERT_EQ(bind(ls, (struct sockaddr *)&addr, sizeof(addr)), 0); + ASSERT_EQ(listen(ls, 1), 0); + /* Winsock sockets are inheritable by default; make it explicit so a _popen + * regression is guaranteed to leak it (and this test to go RED). */ + ASSERT(SetHandleInformation((HANDLE)ls, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT)); + + char self[MAX_PATH]; + ASSERT(GetModuleFileNameA(NULL, self, sizeof(self)) > 0); + + char cmd[MAX_PATH + 64]; + snprintf(cmd, sizeof(cmd), "\"%s\" __cbm_sockprobe %llu", self, + (unsigned long long)(uintptr_t)ls); + + FILE *fp = cbm_popen(cmd, "r"); + ASSERT_NOT_NULL(fp); + ASSERT_EQ(cbm_popen_last_was_isolated(), 1); + char drain[128]; + while (fgets(drain, sizeof(drain), fp)) { + /* the probe writes nothing to stdout, but drain to a clean EOF */ + } + int rc = cbm_pclose(fp); + closesocket(ls); + WSACleanup(); + + ASSERT_EQ(rc, 0); /* 0 = socket isolated from child; 42 = leaked (regression) */ + PASS(); +} + #endif /* _WIN32 */ /* ══════════════════════════════════════════════════════════════════ @@ -629,5 +686,6 @@ SUITE(security) { /* Isolated popen — handle-inheritance regression guard for #798 */ RUN_TEST(popen_isolated_git_version_round_trip); RUN_TEST(popen_isolated_propagates_exit_code); + RUN_TEST(popen_isolates_listening_socket); #endif }