|
| 1 | +// Regression test for the Windows first-run hang where xlings / xim / curl / |
| 2 | +// git child processes blocked on terminal stdin, forcing the user to press |
| 3 | +// Enter repeatedly to advance bootstrap / toolchain install. |
| 4 | +// |
| 5 | +// `mcpp::platform::process::{capture, run_silent, run_streaming}` MUST seal |
| 6 | +// stdin so any child reading stdin gets immediate EOF, not a blocking read. |
| 7 | +// POSIX: appends "</dev/null" |
| 8 | +// Windows: appends "<NUL" |
| 9 | +// |
| 10 | +// Test strategy: rebind this test process's own stdin to an open, empty, |
| 11 | +// never-closing pipe. Then invoke run_silent / capture with a child that |
| 12 | +// reads stdin. Without seal_stdin, the child would inherit our pipe stdin |
| 13 | +// and block forever; the gtest runner would then hang until CI timeout. |
| 14 | +// With the fix, the child reads from NUL / /dev/null and exits immediately. |
| 15 | + |
| 16 | +#include <gtest/gtest.h> |
| 17 | +#include <chrono> |
| 18 | + |
| 19 | +#if defined(_WIN32) |
| 20 | +#include <windows.h> |
| 21 | +#include <io.h> |
| 22 | +#include <fcntl.h> |
| 23 | +#else |
| 24 | +#include <fcntl.h> |
| 25 | +#include <unistd.h> |
| 26 | +#endif |
| 27 | + |
| 28 | +import std; |
| 29 | +import mcpp.platform.process; |
| 30 | + |
| 31 | +namespace { |
| 32 | + |
| 33 | +// Maximum seconds a sealed-stdin command may take before we declare it |
| 34 | +// "hung". Real child runs (cat / more reading from EOF stdin) complete in |
| 35 | +// well under 100ms on any modern machine, so 5s is a very generous bound. |
| 36 | +constexpr int kMaxSealedSeconds = 5; |
| 37 | + |
| 38 | +// RAII: rebind STDIN to an open, empty, never-closing pipe for the duration |
| 39 | +// of one test. Restores the original stdin on destruction. |
| 40 | +class OpenEmptyStdinScope { |
| 41 | +public: |
| 42 | + OpenEmptyStdinScope() { |
| 43 | +#if defined(_WIN32) |
| 44 | + SECURITY_ATTRIBUTES sa{}; |
| 45 | + sa.nLength = sizeof(sa); |
| 46 | + sa.bInheritHandle = TRUE; |
| 47 | + if (!CreatePipe(&hRead_, &hWrite_, &sa, 0)) { |
| 48 | + std::abort(); |
| 49 | + } |
| 50 | + // Make the read end inheritable (already is via sa, but be explicit). |
| 51 | + SetHandleInformation(hRead_, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); |
| 52 | + |
| 53 | + // Save the original stdin (both Win32 handle + CRT fd) so we can |
| 54 | + // restore in the destructor. |
| 55 | + origStdinHandle_ = GetStdHandle(STD_INPUT_HANDLE); |
| 56 | + origStdinFd_ = _dup(0); |
| 57 | + |
| 58 | + // Bind the pipe-read-end as our process's stdin at both layers. |
| 59 | + SetStdHandle(STD_INPUT_HANDLE, hRead_); |
| 60 | + int newFd = _open_osfhandle(reinterpret_cast<intptr_t>(hRead_), |
| 61 | + _O_RDONLY | _O_BINARY); |
| 62 | + if (newFd >= 0) { |
| 63 | + _dup2(newFd, 0); |
| 64 | + _close(newFd); // _dup2 keeps a reference; we're done with newFd |
| 65 | + } |
| 66 | +#else |
| 67 | + if (::pipe(pipeFds_) != 0) std::abort(); |
| 68 | + origStdinFd_ = ::dup(0); |
| 69 | + ::dup2(pipeFds_[0], 0); |
| 70 | +#endif |
| 71 | + } |
| 72 | + |
| 73 | + ~OpenEmptyStdinScope() { |
| 74 | +#if defined(_WIN32) |
| 75 | + // Restore original stdin. |
| 76 | + if (origStdinFd_ >= 0) { |
| 77 | + _dup2(origStdinFd_, 0); |
| 78 | + _close(origStdinFd_); |
| 79 | + } |
| 80 | + SetStdHandle(STD_INPUT_HANDLE, origStdinHandle_); |
| 81 | + if (hWrite_) CloseHandle(hWrite_); |
| 82 | + if (hRead_) CloseHandle(hRead_); |
| 83 | +#else |
| 84 | + if (origStdinFd_ >= 0) { |
| 85 | + ::dup2(origStdinFd_, 0); |
| 86 | + ::close(origStdinFd_); |
| 87 | + } |
| 88 | + ::close(pipeFds_[0]); |
| 89 | + ::close(pipeFds_[1]); |
| 90 | +#endif |
| 91 | + } |
| 92 | + |
| 93 | + OpenEmptyStdinScope(const OpenEmptyStdinScope&) = delete; |
| 94 | + OpenEmptyStdinScope& operator=(const OpenEmptyStdinScope&) = delete; |
| 95 | + |
| 96 | +private: |
| 97 | +#if defined(_WIN32) |
| 98 | + HANDLE hRead_ = nullptr; |
| 99 | + HANDLE hWrite_ = nullptr; // intentionally never written → reader blocks |
| 100 | + HANDLE origStdinHandle_ = nullptr; |
| 101 | + int origStdinFd_ = -1; |
| 102 | +#else |
| 103 | + int pipeFds_[2] = {-1, -1}; // intentionally never written → reader blocks |
| 104 | + int origStdinFd_ = -1; |
| 105 | +#endif |
| 106 | +}; |
| 107 | + |
| 108 | +// A child command that reads stdin to EOF and exits. |
| 109 | +// With seal_stdin in effect → stdin is NUL / /dev/null → child exits immediately. |
| 110 | +// Without seal_stdin AND with an open-empty parent stdin → child blocks forever. |
| 111 | +constexpr std::string_view kStdinReaderCmd = |
| 112 | +#if defined(_WIN32) |
| 113 | + "more >nul 2>&1" |
| 114 | +#else |
| 115 | + "cat >/dev/null" |
| 116 | +#endif |
| 117 | + ; |
| 118 | + |
| 119 | +template <class F> |
| 120 | +double time_seconds(F&& fn) { |
| 121 | + auto t0 = std::chrono::steady_clock::now(); |
| 122 | + fn(); |
| 123 | + auto t1 = std::chrono::steady_clock::now(); |
| 124 | + return std::chrono::duration<double>(t1 - t0).count(); |
| 125 | +} |
| 126 | + |
| 127 | +} // namespace |
| 128 | + |
| 129 | +// run_silent: must seal stdin so the child does not inherit our pipe stdin |
| 130 | +// and block forever. |
| 131 | +TEST(ProcessSealStdin, RunSilentDoesNotHangWhenParentStdinIsOpenPipe) { |
| 132 | + OpenEmptyStdinScope scope; |
| 133 | + double elapsed = time_seconds([] { |
| 134 | + (void)mcpp::platform::process::run_silent(kStdinReaderCmd); |
| 135 | + }); |
| 136 | + EXPECT_LT(elapsed, static_cast<double>(kMaxSealedSeconds)) |
| 137 | + << "run_silent took " << elapsed |
| 138 | + << "s — child blocked on stdin → seal_stdin is broken or not applied"; |
| 139 | +} |
| 140 | + |
| 141 | +// capture: must also seal stdin (it shares seal_stdin with run_silent). |
| 142 | +TEST(ProcessSealStdin, CaptureDoesNotHangWhenParentStdinIsOpenPipe) { |
| 143 | + OpenEmptyStdinScope scope; |
| 144 | + double elapsed = time_seconds([] { |
| 145 | + (void)mcpp::platform::process::capture(kStdinReaderCmd); |
| 146 | + }); |
| 147 | + EXPECT_LT(elapsed, static_cast<double>(kMaxSealedSeconds)) |
| 148 | + << "capture took " << elapsed |
| 149 | + << "s — child blocked on stdin → seal_stdin is broken or not applied"; |
| 150 | +} |
| 151 | + |
| 152 | +// run_streaming: same property — children spawned via popen-streaming must |
| 153 | +// not inherit a live stdin. |
| 154 | +TEST(ProcessSealStdin, RunStreamingDoesNotHangWhenParentStdinIsOpenPipe) { |
| 155 | + OpenEmptyStdinScope scope; |
| 156 | + double elapsed = time_seconds([] { |
| 157 | + (void)mcpp::platform::process::run_streaming( |
| 158 | + kStdinReaderCmd, |
| 159 | + [](std::string_view) {}); |
| 160 | + }); |
| 161 | + EXPECT_LT(elapsed, static_cast<double>(kMaxSealedSeconds)) |
| 162 | + << "run_streaming took " << elapsed |
| 163 | + << "s — child blocked on stdin → seal_stdin is broken or not applied"; |
| 164 | +} |
0 commit comments