CLI: Fix hang with exec/run -i with self-exiting command and open stdin (#40921)

David Bennett committed Jun 30, 2026 at 10:57 UTC a91e1c8669e52f281420b5d23bd00c89b51ef9ef
4 files changed +146 -15
src/windows/common/relay.cpp
+1 -1
@@ -133,7 +133,7 @@ wsl::windows::common::relay::InterruptableRead(
133 if (!ReadFile(InputHandle, Buffer.data(), gsl::narrow_cast<DWORD>(Buffer.size()), &bytesRead, Overlapped))
134 {
135 auto lastError = GetLastError();
136 - if ((lastError == ERROR_HANDLE_EOF) || (lastError == ERROR_BROKEN_PIPE))
136 + if ((lastError == ERROR_HANDLE_EOF) || (lastError == ERROR_BROKEN_PIPE) || (lastError == ERROR_OPERATION_ABORTED))
137 {
138 return 0;
139 }
src/windows/wslc/services/ConsoleService.cpp
+46 -14
@@ -24,6 +24,50 @@ using wsl::windows::common::io::ReadConsoleHandle;
24 using wsl::windows::common::io::ReadHandle;
25 using wsl::windows::common::io::RelayHandle;
26
27 +namespace {
28 +
29 + // Interrupts and joins the stdin-relay worker thread at teardown.
30 + //
31 + // The worker only exists when stdin is not a character device (any non-FILE_TYPE_CHAR handle, e.g. a
32 + // redirected pipe), so this no-ops (not joinable) for a console. When stdin is a non-overlapped
33 + // (synchronous) pipe the worker blocks in a ReadFile() that neither the exit event nor CancelIoEx() can
34 + // interrupt, so the join() below would hang until stdin is closed -- the bug this guards against.
35 + void InterruptAndJoinInputThread(std::thread& inputThread, wil::unique_event& exitEvent)
36 + {
37 + if (!inputThread.joinable())
38 + {
39 + return;
40 + }
41 +
42 + WI_ASSERT(exitEvent);
43 + exitEvent.SetEvent();
44 +
45 + // Overlapped IO will get terminated by SetEvent(). Synchronous IO will not, so we need to cancel it.
46 + const auto threadHandle = static_cast<HANDLE>(inputThread.native_handle());
47 + DWORD waitResult = WAIT_TIMEOUT;
48 + while (waitResult == WAIT_TIMEOUT)
49 + {
50 + if (!CancelSynchronousIo(threadHandle))
51 + {
52 + // ERROR_NOT_FOUND means nothing to cancel; any other error is a corrupt handle that shouldn't happen.
53 + const auto cancelError = GetLastError();
54 + if (cancelError != ERROR_NOT_FOUND)
55 + {
56 + FAIL_FAST_WIN32(cancelError);
57 + }
58 + }
59 +
60 + waitResult = WaitForSingleObject(threadHandle, 50);
61 + }
62 +
63 + // Anything but WAIT_OBJECT_0 (e.g. WAIT_FAILED) means a corrupt handle that shouldn't happen.
64 + FAIL_FAST_LAST_ERROR_IF(waitResult != WAIT_OBJECT_0);
65 +
66 + inputThread.join();
67 + }
68 +
69 +} // namespace
70 +
71 bool ConsoleService::RelayInteractiveTty(wsl::windows::common::ConsoleState& Console, ClientRunningWSLCProcess& Process, HANDLE Tty, bool TriggerRefresh)
72 {
73 // Configure the console for interactive usage.
@@ -43,13 +87,7 @@ bool ConsoleService::RelayInteractiveTty(wsl::windows::common::ConsoleState& Con
87 wil::unique_event exitEvent;
88 std::thread inputThread;
89
46 - auto joinThread = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
47 - if (inputThread.joinable())
48 - {
49 - exitEvent.SetEvent();
50 - inputThread.join();
51 - }
52 - });
90 + auto joinThread = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { InterruptAndJoinInputThread(inputThread, exitEvent); });
91
92 bool detached = false;
93 MultiHandleWait io;
@@ -99,13 +137,7 @@ void ConsoleService::RelayNonTtyProcess(wil::unique_handle&& Stdin, wil::unique_
137 wil::unique_event exitEvent;
138 std::thread inputThread;
139
102 - auto joinThread = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
103 - if (inputThread.joinable())
104 - {
105 - exitEvent.SetEvent();
106 - inputThread.join();
107 - }
108 - });
140 + auto joinThread = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { InterruptAndJoinInputThread(inputThread, exitEvent); });
141
142 if (Stdin.is_valid())
143 {
test/windows/wslc/e2e/WSLCE2EContainerExecTests.cpp
+57
@@ -154,6 +154,63 @@ class WSLCE2EContainerExecTests
154 session.VerifyNoErrors();
155 }
156
157 + WSLC_TEST_METHOD(WSLCE2E_Container_Exec_InteractiveNoTTY_SelfExitingCommand)
158 + {
159 + // Regression test for a stdin deadlock. When stdin is a synchronous (non-overlapped) anonymous pipe, the client
160 + // relays it on a worker thread parked in a blocking ReadFile() that the exit event cannot interrupt. With
161 + // `echo hello` (which exits without reading stdin), teardown's join() on that worker blocks until stdin closes,
162 + // so wslc hangs. The test exercises this by running `exec -i echo hello` and requiring it to exit while the
163 + // client keeps stdin open.
164 + //
165 + // If this regresses, look at the client-side stdin relay teardown: InterruptAndJoinInputThread
166 + // (the CancelSynchronousIo retry loop that unblocks the worker) and relay.cpp's InterruptableRead (which maps
167 + // the resulting ERROR_OPERATION_ABORTED to EOF).
168 + VerifyContainerIsNotListed(WslcContainerName);
169 + auto result = RunWslc(std::format(L"container run -id --name {} {}", WslcContainerName, DebianImage.NameAndTag()));
170 + result.Verify({.Stderr = L"", .ExitCode = 0});
171 + auto containerId = result.GetStdoutOneLine();
172 +
173 + // RunWslcInteractive wires wslc's stdin to the read end of a synchronous (non-overlapped) anonymous pipe, which
174 + // is what triggers the blocking-ReadFile relay path under test.
175 + auto session = RunWslcInteractive(std::format(L"container exec -i {} echo hello", containerId));
176 +
177 + // The command's output must arrive without the client closing stdin first.
178 + session.ExpectStdout("hello\n");
179 +
180 + // Long timeout: this only bounds the failure (hang) path, so it is generous to avoid false positives under CI load.
181 + auto exitCode = session.Wait(120000);
182 + VERIFY_ARE_EQUAL(0, exitCode, L"echo should exit with code 0 without the client closing stdin");
183 +
184 + // Closing stdin after the process has already exited must remain a clean no-op with no errors emitted.
185 + session.CloseStdin();
186 + session.VerifyNoErrors();
187 + }
188 +
189 + WSLC_TEST_METHOD(WSLCE2E_Container_Exec_InteractiveTTY_SelfExitingCommand)
190 + {
191 + // TTY counterpart to WSLCE2E_Container_Exec_InteractiveNoTTY_SelfExitingCommand (see that test for the full
192 + // explanation of the deadlock). The -t flag routes wslc through ConsoleService::RelayInteractiveTty, whose
193 + // stdin worker teardown is a separate scope-exit from the non-TTY path, so a regression could be introduced
194 + // in one path and not the other. This test guards the TTY call site.
195 + VerifyContainerIsNotListed(WslcContainerName);
196 + auto result = RunWslc(std::format(L"container run -id --name {} {}", WslcContainerName, DebianImage.NameAndTag()));
197 + result.Verify({.Stderr = L"", .ExitCode = 0});
198 + auto containerId = result.GetStdoutOneLine();
199 +
200 + // -t sets the TTY flag; the harness still wires stdin as a synchronous pipe, so wslc takes the vulnerable
201 + // RelayInteractiveTty else-branch (its input handle is a pipe, not a console).
202 + auto session = RunWslcInteractive(std::format(L"container exec -it {} echo hello", containerId));
203 +
204 + // The TTY translates the trailing LF to CRLF, so the exact output is "hello\r\n".
205 + session.ExpectStdout("hello\r\n");
206 +
207 + auto exitCode = session.Wait(120000);
208 + VERIFY_ARE_EQUAL(0, exitCode, L"echo should exit with code 0 without the client closing stdin");
209 +
210 + session.CloseStdin();
211 + session.VerifyNoErrors();
212 + }
213 +
214 WSLC_TEST_METHOD(WSLCE2E_Container_Exec_PseudoConsole_TerminalSize)
215 {
216 VerifyContainerIsNotListed(WslcContainerName);
test/windows/wslc/e2e/WSLCE2EContainerRunTests.cpp
+42
@@ -715,6 +715,48 @@ class WSLCE2EContainerRunTests
715 session.VerifyNoErrors();
716 }
717
718 + WSLC_TEST_METHOD(WSLCE2E_Container_Run_InteractiveNoTTY_SelfExitingCommand)
719 + {
720 + // Same stdin-relay teardown deadlock as WSLCE2E_Container_Exec_InteractiveNoTTY_SelfExitingCommand (see it
721 + // for the root cause), but via `container run -i`. run and exec share the client relay (both route through
722 + // AttachToCurrentConsole), so the hang is not exec-specific. The test requires run to exit with the client
723 + // still holding stdin open; RunWslcInteractive supplies stdin as a synchronous (non-overlapped) pipe, the
724 + // case that triggers the bug.
725 + VerifyContainerIsNotListed(WslcContainerName);
726 +
727 + auto session =
728 + RunWslcInteractive(std::format(L"container run -i --name {} {} echo hello", WslcContainerName, DebianImage.NameAndTag()));
729 +
730 + session.ExpectStdout("hello\n");
731 +
732 + // Generous timeout: it only bounds the failure (hang) path.
733 + auto exitCode = session.Wait(120000);
734 + VERIFY_ARE_EQUAL(0, exitCode, L"echo should exit with code 0 without the client closing stdin");
735 +
736 + // Closing stdin after exit must stay a clean no-op.
737 + session.CloseStdin();
738 + session.VerifyNoErrors();
739 + }
740 +
741 + WSLC_TEST_METHOD(WSLCE2E_Container_Run_InteractiveTTY_SelfExitingCommand)
742 + {
743 + // TTY counterpart of the above: `-t` routes through ConsoleService::RelayInteractiveTty, a separate
744 + // stdin-worker teardown from the non-TTY path, so it could regress independently. Guards the TTY run path.
745 + VerifyContainerIsNotListed(WslcContainerName);
746 +
747 + auto session =
748 + RunWslcInteractive(std::format(L"container run -it --name {} {} echo hello", WslcContainerName, DebianImage.NameAndTag()));
749 +
750 + // The TTY translates the trailing LF to CRLF.
751 + session.ExpectStdout("hello\r\n");
752 +
753 + auto exitCode = session.Wait(120000);
754 + VERIFY_ARE_EQUAL(0, exitCode, L"echo should exit with code 0 without the client closing stdin");
755 +
756 + session.CloseStdin();
757 + session.VerifyNoErrors();
758 + }
759 +
760 WSLC_TEST_METHOD(WSLCE2E_Container_Run_PseudoConsole_TerminalSize)
761 {
762 VerifyContainerIsNotListed(WslcContainerName);