|
21 | 21 | #endif |
22 | 22 | #include <windows.h> |
23 | 23 | #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" |
25 | 29 | #include "foundation/win_utf8.h" |
26 | 30 |
|
27 | 31 | struct cbm_dir { |
@@ -123,12 +127,253 @@ void cbm_closedir(cbm_dir_t *d) { |
123 | 127 | } |
124 | 128 | } |
125 | 129 |
|
| 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 | + |
126 | 330 | 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; |
127 | 350 | return _popen(cmd, mode); |
128 | 351 | } |
129 | 352 |
|
130 | 353 | 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; |
132 | 377 | } |
133 | 378 |
|
134 | 379 | FILE *cbm_fopen(const char *path, const char *mode) { |
|
0 commit comments