Skip to content

Commit d2a5975

Browse files
authored
Merge pull request #846 from DeusData/distill/799-win-handles
fix(windows): spawn git without inheriting handles (UI hang)
2 parents 896aa91 + 40e317e commit d2a5975

3 files changed

Lines changed: 301 additions & 2 deletions

File tree

src/foundation/compat_fs.c

Lines changed: 247 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
#endif
2222
#include <windows.h>
2323
#include <direct.h> /* _wmkdir */
24-
#include <io.h> /* _wunlink */
24+
#include <errno.h> /* errno for spawn-failure logging */
25+
#include <fcntl.h> /* _O_RDONLY */
26+
#include <io.h> /* _wunlink, _open_osfhandle, _close */
27+
#include <stdint.h> /* intptr_t */
28+
#include "foundation/log.h"
2529
#include "foundation/win_utf8.h"
2630

2731
struct cbm_dir {
@@ -123,12 +127,253 @@ void cbm_closedir(cbm_dir_t *d) {
123127
}
124128
}
125129

130+
/* Windows _popen replacement that inherits ONLY the child's stdout pipe.
131+
*
132+
* The CRT's _popen uses CreateProcess(bInheritHandles=TRUE), which leaks EVERY
133+
* inheritable handle we hold into the child — listening/client sockets, the
134+
* Winsock/AFD helper handles created by WSAStartup, the MCP stdio pipe, etc.
135+
* When the child is git-for-Windows (MSYS2/Cygwin runtime), its startup walks
136+
* every inherited handle and calls NtQueryObject on each to classify it; on an
137+
* inherited socket/AFD handle NtQueryObject deadlocks. Since our UI server runs
138+
* requests on a single thread, that wedges the whole server (list_projects,
139+
* which shells out to git per project, never returns → the web UI hangs).
140+
*
141+
* The fix: spawn via CreateProcessW with STARTUPINFOEXW + an explicit
142+
* PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing only the stdout write-end and a
143+
* NUL handle for stdin/stderr. Nothing else crosses into git, so there is no
144+
* foreign handle to deadlock on. POSIX popen() already sets O_CLOEXEC on its
145+
* pipe, so the POSIX path is unchanged.
146+
*
147+
* There is deliberately NO fallback to _popen when the isolated spawn fails:
148+
* falling back would silently re-arm the deadlock. cbm_popen logs a structured
149+
* warning and returns NULL instead (every call site handles NULL). */
150+
151+
enum { CBM_POPEN_MAX = 16 };
152+
static struct {
153+
FILE *fp;
154+
HANDLE proc;
155+
} g_popen_tab[CBM_POPEN_MAX];
156+
static CRITICAL_SECTION g_popen_lock;
157+
static INIT_ONCE g_popen_once = INIT_ONCE_STATIC_INIT;
158+
159+
/* Test hook (declared in compat_fs_internal.h): 1 when the most recent
160+
* cbm_popen(..., "r") stream came from the isolated spawn. Test-only
161+
* observable; not synchronized across threads. */
162+
static volatile LONG g_popen_last_isolated = 0;
163+
164+
int cbm_popen_last_was_isolated(void) {
165+
return (int)g_popen_last_isolated;
166+
}
167+
168+
static BOOL CALLBACK cbm_popen_init(PINIT_ONCE once, PVOID param, PVOID *ctx) {
169+
(void)once;
170+
(void)param;
171+
(void)ctx;
172+
InitializeCriticalSection(&g_popen_lock);
173+
return TRUE;
174+
}
175+
176+
/* Resolve the shell explicitly — %COMSPEC%, else <system dir>\cmd.exe — so it
177+
* can be passed as lpApplicationName and CreateProcess never walks the search
178+
* path (no cmd.exe planting from a hostile CWD). Heap string; caller frees. */
179+
static wchar_t *cbm_resolve_comspec(void) {
180+
wchar_t buf[MAX_PATH];
181+
const wchar_t suffix[] = L"\\cmd.exe";
182+
DWORD n = GetEnvironmentVariableW(L"COMSPEC", buf, MAX_PATH);
183+
if (n == 0 || n >= MAX_PATH) {
184+
UINT sn = GetSystemDirectoryW(buf, MAX_PATH);
185+
if (sn == 0 || (size_t)sn + wcslen(suffix) >= MAX_PATH) {
186+
return NULL;
187+
}
188+
wmemcpy(buf + sn, suffix, wcslen(suffix) + 1);
189+
}
190+
return _wcsdup(buf);
191+
}
192+
193+
/* On failure returns NULL with *stage naming the failing step and *gle the
194+
* GetLastError value captured at that step (0 when errno is the signal). */
195+
static FILE *cbm_popen_isolated(const char *cmd, const char **stage, DWORD *gle) {
196+
*stage = "";
197+
*gle = 0;
198+
InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL);
199+
200+
SECURITY_ATTRIBUTES sa;
201+
sa.nLength = sizeof(sa);
202+
sa.lpSecurityDescriptor = NULL;
203+
sa.bInheritHandle = TRUE;
204+
205+
HANDLE rd = NULL, wr = NULL;
206+
if (!CreatePipe(&rd, &wr, &sa, 0)) {
207+
*stage = "pipe";
208+
*gle = GetLastError();
209+
return NULL;
210+
}
211+
/* The parent read-end must never cross into the child. */
212+
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
213+
214+
/* NUL for the child's stdin/stderr so it never touches our real stdin
215+
* pipe. If NUL cannot be opened, fail: STARTF_USESTDHANDLES slots must
216+
* never carry INVALID_HANDLE_VALUE. */
217+
HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE,
218+
FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL);
219+
if (nul == INVALID_HANDLE_VALUE) {
220+
*stage = "nul";
221+
*gle = GetLastError();
222+
CloseHandle(rd);
223+
CloseHandle(wr);
224+
return NULL;
225+
}
226+
227+
HANDLE inherit[2];
228+
inherit[0] = wr;
229+
inherit[1] = nul;
230+
231+
SIZE_T attr_sz = 0;
232+
InitializeProcThreadAttributeList(NULL, 1, 0, &attr_sz);
233+
LPPROC_THREAD_ATTRIBUTE_LIST attr = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attr_sz);
234+
BOOL attr_init = attr && InitializeProcThreadAttributeList(attr, 1, 0, &attr_sz);
235+
BOOL prepared =
236+
attr_init && UpdateProcThreadAttribute(attr, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit,
237+
sizeof(inherit), NULL, NULL);
238+
DWORD attr_gle = prepared ? 0 : GetLastError();
239+
240+
STARTUPINFOEXW si;
241+
ZeroMemory(&si, sizeof(si));
242+
si.StartupInfo.cb = sizeof(si);
243+
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
244+
si.StartupInfo.hStdInput = nul;
245+
si.StartupInfo.hStdOutput = wr;
246+
si.StartupInfo.hStdError = nul;
247+
si.lpAttributeList = attr;
248+
249+
/* Run through cmd.exe /c so command quoting and `2>NUL` behave as under
250+
* _popen. The command line is heap-composed (no fixed-size truncation)
251+
* and widened via UTF-8 so non-ASCII repo paths survive intact. */
252+
wchar_t *app = cbm_resolve_comspec();
253+
wchar_t *wcmdline = NULL;
254+
if (app) {
255+
size_t u8len = strlen(cmd) + sizeof("cmd.exe /c ");
256+
char *u8 = (char *)malloc(u8len);
257+
if (u8) {
258+
snprintf(u8, u8len, "cmd.exe /c %s", cmd);
259+
wcmdline = cbm_utf8_to_wide(u8);
260+
free(u8);
261+
}
262+
}
263+
264+
PROCESS_INFORMATION pi;
265+
ZeroMemory(&pi, sizeof(pi));
266+
BOOL created = FALSE;
267+
if (!prepared) {
268+
*stage = "attr";
269+
*gle = attr_gle;
270+
} else if (!app || !wcmdline) {
271+
*stage = "cmdline";
272+
*gle = ERROR_NOT_ENOUGH_MEMORY;
273+
} else {
274+
created = CreateProcessW(app, wcmdline, NULL, NULL, TRUE, EXTENDED_STARTUPINFO_PRESENT,
275+
NULL, NULL, &si.StartupInfo, &pi);
276+
if (!created) {
277+
*stage = "spawn";
278+
*gle = GetLastError();
279+
}
280+
}
281+
282+
free(app);
283+
free(wcmdline);
284+
if (attr) {
285+
if (attr_init) {
286+
DeleteProcThreadAttributeList(attr);
287+
}
288+
free(attr);
289+
}
290+
CloseHandle(wr); /* the child owns the write-end now */
291+
CloseHandle(nul);
292+
if (!created) {
293+
CloseHandle(rd);
294+
return NULL;
295+
}
296+
CloseHandle(pi.hThread);
297+
298+
int fd = _open_osfhandle((intptr_t)rd, _O_RDONLY);
299+
if (fd == -1) {
300+
*stage = "osfhandle";
301+
CloseHandle(rd);
302+
CloseHandle(pi.hProcess);
303+
return NULL;
304+
}
305+
FILE *fp = _fdopen(fd, "r"); /* takes ownership of fd/rd */
306+
if (!fp) {
307+
*stage = "fdopen";
308+
_close(fd);
309+
CloseHandle(pi.hProcess);
310+
return NULL;
311+
}
312+
313+
EnterCriticalSection(&g_popen_lock);
314+
for (int i = 0; i < CBM_POPEN_MAX; i++) {
315+
if (!g_popen_tab[i].fp) {
316+
g_popen_tab[i].fp = fp;
317+
g_popen_tab[i].proc = pi.hProcess;
318+
LeaveCriticalSection(&g_popen_lock);
319+
return fp;
320+
}
321+
}
322+
LeaveCriticalSection(&g_popen_lock);
323+
/* Table full (shouldn't happen): don't leak the process handle. */
324+
*stage = "table";
325+
CloseHandle(pi.hProcess);
326+
fclose(fp);
327+
return NULL;
328+
}
329+
126330
FILE *cbm_popen(const char *cmd, const char *mode) {
331+
/* Our git shell-outs are all read-mode; they MUST use the isolated
332+
* spawn. On failure, log and fail the call — never fall back to
333+
* _popen, whose full handle inheritance re-arms the UI hang (#798). */
334+
if (mode && mode[0] == 'r' && mode[1] == '\0') {
335+
const char *stage = "";
336+
DWORD gle = 0;
337+
FILE *fp = cbm_popen_isolated(cmd, &stage, &gle);
338+
g_popen_last_isolated = (fp != NULL);
339+
if (!fp) {
340+
char glebuf[CBM_SZ_16];
341+
char errnobuf[CBM_SZ_16];
342+
snprintf(glebuf, sizeof(glebuf), "%lu", (unsigned long)gle);
343+
snprintf(errnobuf, sizeof(errnobuf), "%d", errno);
344+
cbm_log_warn("compat.popen_isolated_failed", "stage", stage, "gle", glebuf, "errno",
345+
errnobuf);
346+
}
347+
return fp;
348+
}
349+
g_popen_last_isolated = 0;
127350
return _popen(cmd, mode);
128351
}
129352

130353
int cbm_pclose(FILE *f) {
131-
return _pclose(f);
354+
InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL);
355+
356+
HANDLE proc = NULL;
357+
EnterCriticalSection(&g_popen_lock);
358+
for (int i = 0; i < CBM_POPEN_MAX; i++) {
359+
if (g_popen_tab[i].fp == f) {
360+
proc = g_popen_tab[i].proc;
361+
g_popen_tab[i].fp = NULL;
362+
g_popen_tab[i].proc = NULL;
363+
break;
364+
}
365+
}
366+
LeaveCriticalSection(&g_popen_lock);
367+
368+
if (!proc) {
369+
return _pclose(f); /* opened via _popen (non-read mode) */
370+
}
371+
fclose(f);
372+
WaitForSingleObject(proc, INFINITE);
373+
DWORD code = 0;
374+
BOOL got = GetExitCodeProcess(proc, &code);
375+
CloseHandle(proc);
376+
return got ? (int)code : -1;
132377
}
133378

134379
FILE *cbm_fopen(const char *path, const char *mode) {

src/foundation/compat_fs_internal.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@
3131
*/
3232
wchar_t *cbm_build_cmdline(const char *const *argv);
3333

34+
/*
35+
* Test hook for the isolated popen path (#798): returns 1 when the most
36+
* recent cbm_popen(..., "r") stream was produced by the isolated
37+
* CreateProcessW + PROC_THREAD_ATTRIBUTE_HANDLE_LIST spawn, 0 otherwise
38+
* (e.g. a non-read mode routed to _popen, or a failed isolated spawn).
39+
* Not synchronized across threads; intended for single-threaded test
40+
* assertions only.
41+
*/
42+
int cbm_popen_last_was_isolated(void);
43+
3444
#endif /* _WIN32 */
3545

3646
#endif /* CBM_FOUNDATION_COMPAT_FS_INTERNAL_H */

tests/test_security.c

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,47 @@ TEST(exec_no_shell_win_null_argv_returns_error) {
517517
PASS();
518518
}
519519

520+
/* ──────────────────────────────────────────────────────────────────
521+
* WINDOWS ISOLATED POPEN (handle-inheritance fix for #798)
522+
*
523+
* Regression guard for the UI hang: _popen spawns children with
524+
* bInheritHandles=TRUE, leaking every inheritable handle (listening
525+
* sockets, Winsock/AFD helpers) into git-for-Windows, whose MSYS2
526+
* runtime hangs classifying them via NtQueryObject. cbm_popen must
527+
* instead spawn via CreateProcessW + PROC_THREAD_ATTRIBUTE_HANDLE_LIST.
528+
*
529+
* On windows-latest CI (real git-for-Windows) these prove:
530+
* - the returned stream came from the isolated spawn, not _popen
531+
* (cbm_popen_last_was_isolated test hook) — a revert to raw _popen
532+
* turns the hook 0 and fails the guard;
533+
* - stdout and the child exit code round-trip through
534+
* cbm_popen/cbm_pclose.
535+
* NOT proven here: the full UI repro (listening socket + MSYS2 handle
536+
* walk under a single-threaded server) — follow-up harness.
537+
* ────────────────────────────────────────────────────────────────── */
538+
539+
TEST(popen_isolated_git_version_round_trip) {
540+
FILE *fp = cbm_popen("git --version", "r");
541+
ASSERT_NOT_NULL(fp);
542+
ASSERT_EQ(cbm_popen_last_was_isolated(), 1);
543+
char line[256];
544+
line[0] = '\0';
545+
ASSERT_NOT_NULL(fgets(line, sizeof(line), fp));
546+
ASSERT(strncmp(line, "git version", strlen("git version")) == 0);
547+
ASSERT_EQ(cbm_pclose(fp), 0);
548+
PASS();
549+
}
550+
551+
TEST(popen_isolated_propagates_exit_code) {
552+
/* Runs under `cmd.exe /c`, so a bare `exit 7` is the child exit code;
553+
* cbm_pclose must surface it via GetExitCodeProcess. */
554+
FILE *fp = cbm_popen("exit 7", "r");
555+
ASSERT_NOT_NULL(fp);
556+
ASSERT_EQ(cbm_popen_last_was_isolated(), 1);
557+
ASSERT_EQ(cbm_pclose(fp), 7);
558+
PASS();
559+
}
560+
520561
#endif /* _WIN32 */
521562

522563
/* ══════════════════════════════════════════════════════════════════
@@ -585,5 +626,8 @@ SUITE(security) {
585626
RUN_TEST(exec_no_shell_win_exit_zero);
586627
RUN_TEST(exec_no_shell_win_captures_exit_code);
587628
RUN_TEST(exec_no_shell_win_null_argv_returns_error);
629+
/* Isolated popen — handle-inheritance regression guard for #798 */
630+
RUN_TEST(popen_isolated_git_version_round_trip);
631+
RUN_TEST(popen_isolated_propagates_exit_code);
588632
#endif
589633
}

0 commit comments

Comments
 (0)