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);