Fix WSLC exec hang on fast runc failure (e.g. invalid user/group) (#40550)

WSLCContainerImpl::Exec polls Docker's exec inspect endpoint after StartExec to learn whether the user process is running or has already failed. The Running branch was guarded by `state.Pid.has_value()`, which is meaningless because Docker's wire schema declares Pid as a non-nullable Go int that is 0 until runc forks the user process - so the JSON always contains `"Pid": 0` and nlohmann always deserializes that as `optional<int>(0)` with `has_value() == true`. When runc fails before forking (e.g. `-u root:badgid`), Docker briefly reports `{Running: true, Pid: 0, ExitCode: null}` in the window between logging the error and running its deferred cleanup that sets `Running=false, ExitCode=126`. The polling loop accepted Pid=0 as a valid PID, called SetPid(0), broke out, and returned the process to wslc. wslc then waited on the exit event forever, because Docker never emits an `exec_die` event when the user process never spawned. Change InspectExec.Pid from `std::optional<int>` to `int` to match the wire format, and check `state.Pid > 0` at the call site. With this change the loop continues polling on Pid=0; on the next iteration Docker has settled state and the existing ExitCode branch fires with the correct exit code (126). Verified against the failing test WSLCE2EContainerExecTests::WSLCE2E_Container_Exec_UserOption_InvalidGroup_Fails, which is the regression test for this bug. Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Ben Hillis committed May 15, 2026 at 11:58 UTC 5b7206bb8b449795ce4b529559731528210d9bbf
2 files changed +8 -3
src/windows/inc/docker_schema.h
+3 -1
@@ -336,7 +336,9 @@ struct InspectContainer
336
337 struct InspectExec
338 {
339 - std::optional<int> Pid{};
339 + // N.B. Pid is a non-nullable int in moby's schema; it is 0 until runc forks the user process. ExitCode is a *int and
340 + // is null until the exec exits.
341 + int Pid{};
342 std::optional<int> ExitCode{};
343 bool Running{};
344
src/windows/wslcsession/WSLCContainer.cpp
+5 -2
@@ -1157,6 +1157,9 @@ void WSLCContainerImpl::Exec(const WSLCProcessOptions* Options, LPCSTR DetachKey
1157 // Poll for the exec'd process to either be running, or failed.
1158 // This is required because StartExec() returns before the process is actually created, and if exec() fails, we'll never
1159 // get an exec_die notification, so this case needs to be caught before returning the process to the caller.
1160 + //
1161 + // N.B. Pid is 0 until runc forks the user process, so a transient {Running=true, Pid=0} response (seen e.g. on a
1162 + // fast failure such as an invalid user/group) must not be treated as "running" or we'd wait forever.
1163
1164 // TODO: Configurable timeout.
1165 auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30);
@@ -1164,9 +1167,9 @@ void WSLCContainerImpl::Exec(const WSLCProcessOptions* Options, LPCSTR DetachKey
1167 do
1168 {
1169 auto state = m_dockerClient.InspectExec(result.Id);
1167 - if (state.Running && state.Pid.has_value())
1170 + if (state.Running && state.Pid > 0)
1171 {
1169 - control->SetPid(state.Pid.value());
1172 + control->SetPid(state.Pid);
1173 break; // Exec is running, exit.
1174 }
1175 else if (state.ExitCode.has_value())