Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions tests/test_httpd.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -34,13 +35,15 @@
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h> /* #798 follow-up: CreateThread/WaitForSingleObject watchdog */
typedef SOCKET th_sock_t;
#define th_sock_close closesocket
#define TH_SOCK_BAD INVALID_SOCKET
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/time.h> /* struct timeval for the SO_RCVTIMEO watchdog (#798 follow-up) */
#include <unistd.h>
typedef int th_sock_t;
#define th_sock_close close
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
43 changes: 43 additions & 0 deletions tests/test_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ int tf_skip_count = 0;
#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */
#include <sqlite3.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <winsock2.h> /* #798 follow-up: socket-isolation re-exec probe */
#endif

/* #832 guard support: when the index supervisor spawns THIS binary as
* `<self> cli --index-worker index_repository <args_json> --response-out <file>`
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down
60 changes: 59 additions & 1 deletion tests/test_security.c
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
#ifdef _WIN32
#include "../src/foundation/compat_fs_internal.h"
#include "../src/foundation/win_utf8.h"
#include <wchar.h>
#include <winsock2.h> /* #798 follow-up: listening-socket isolation guard */
#include <windows.h>
#include <stdint.h>
#include <stdlib.h>
#include <wchar.h>
#endif

#include <string.h>
Expand Down Expand Up @@ -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 */

/* ══════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -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
}
Loading