Container lifecycle transitions - #41140
Kevin Vega (kvega005) merged 52 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR refactors WSLC container lifecycle handling to coordinate Start/Stop/Delete operations via a shared “transition” object, with event-driven completion, and renames the internal state update helper from Transition to CommitState.
Changes:
- Introduce a
StateTransitionmodel (with completion event/exception propagation) plusm_transitionLock/m_transitionto coordinate lifecycle operations and allow concurrentStop()callers to join an in-flight transition. - Refactor lifecycle flow so
Start(),Stop(), andDelete()publish a transition and wait for corresponding Docker events to complete it. - Extend Docker event tracking to include the
"restart"action (ContainerEvent::Restart).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/windows/wslcsession/WSLCContainer.h | Adds transition coordination primitives and renames Transition → CommitState; updates lifecycle method signatures. |
| src/windows/wslcsession/WSLCContainer.cpp | Implements transition creation/wait/completion and refactors event handling and Stop/Delete behavior around transitions. |
| src/windows/wslcsession/DockerEventTracker.h | Adds ContainerEvent::Restart. |
| src/windows/wslcsession/DockerEventTracker.cpp | Maps Docker "restart" events to ContainerEvent::Restart. |
| SignalInitProcessExit(); | ||
| } | ||
| void WSLCContainerImpl::WaitForTransitionCompletion(const std::shared_ptr<StateTransition>& transition) const | ||
| { |
There was a problem hiding this comment.
would be useful if we could test that terminating the waiting client returns E_ABORT but doesn't invalidate the published transition... that another caller or event can still finish it safely
There was a problem hiding this comment.
I think we can do this with an E2E test, using the CLI. I'll look into it.
| else | ||
| else if (event == ContainerEvent::Stop) | ||
| { | ||
| WI_ASSERT(exitCode.has_value()); |
There was a problem hiding this comment.
exitCode being absent there would be a symptom of a deeper problem, since we'd essentially be a in broken state whatever we do.
If we wanted to be ultra safe here, we have a LOG_HR + default to something like 255
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/windows/wslcsession/WSLCContainer.cpp:1373
- The comment above
waitForStopdoesn’t match the actual logic: for Stop() calls (Kill == false) the expression forceswaitForStopto be true regardless of whether SIGKILL is used. This is confusing for future maintenance and makes it look like Stop() may skip waiting when it cannot.
// Don't wait for the container to stop if we're not sending SIGKILL, since it may not stop the container.
// N.B. If the signal was SIGTERM for instance, we'll receive the stop notification via OnEvent().
bool waitForStop = !Kill || (SignalArg.value_or(WSLCSignalSIGKILL) == WSLCSignalSIGKILL);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/windows/wslcsession/WSLCContainer.cpp:1287
- OnEvent(ContainerEvent::Stop) assumes Docker always provides an exit code. If Docker omits the
exitCodeattribute (or parsing failed upstream),exitCode.value()will throw and because OnEvent isnoexceptthis will terminate the process. Please handle a missing exitCode explicitly (log + use a sentinel) so event delivery can’t bring down the session.
else if (event == ContainerEvent::Stop)
{
WI_ASSERT(exitCode.has_value());
OnStopped(exitCode.value(), eventTime);
}
| // Wait for the stop event to get the Docker timestamp. | ||
| std::optional<std::int64_t> stopTimestamp; | ||
| if (m_wslcSession.WaitForEventOrSessionTerminating(m_stopNotification.Event.get(), 60s)) | ||
| if (transition) |
There was a problem hiding this comment.
If we have thread A doing something like:
wslc stop
And in parallel thread B does:
wslc kill -s SIGUSR1 ,
thread will have waitForStop = false, but will have transition set to the pending stop transition, so when we reach this block, thread B will attach to thread A's transition, which is not what we want.
The easiest fix I can think of would be switching to:
if (waitForStop)
{
lock = m_lock.lock_exclusive();
[...]
}
else
{
transition.reset();
}
It might also be a good idea to add a test case covering that specific potential hang. We could have a purposefully hung stop, followed by a kill with a dummy signal like USR1 in a separate thread, to validate that the second thread doesn't get stuck
Blue (OneBlue)
left a comment
There was a problem hiding this comment.
LGTM, thank you for doing this. This change will give us a much stronger foundation than the previous implementation and will unblock things like parallel stops & wslc restart
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
src/windows/wslcsession/DockerEventTracker.h:143
- InvokeCallbacks() does not guard against exceptions thrown by callback implementations. Some registered callbacks can throw (e.g., WSLCVolumes::OnVolumeEvent() calls THROW_HR_IF_MSG at WSLCVolumes.cpp:52-68), so a single bad volume/container callback would abort dispatch of the remaining callbacks for that event and get reported as a DockerEventParseError by DockerEventTracker::Connect's outer catch, which is misleading.
Consider catching exceptions per-callback inside InvokeCallbacks() (or inside the per-event Invoke lambda) and logging them, while continuing dispatch to the rest of the snapshot.
// Invokes a snapshot of callbacks taken under m_lock, skipping registrations that have since been unregistered.
template <typename TCallback, typename TInvoke>
static void InvokeCallbacks(const std::vector<std::shared_ptr<TCallback>>& Callbacks, const TInvoke& Invoke)
{
for (const auto& e : Callbacks)
{
std::lock_guard invokeLock{e->InvokeLock};
if (!e->Unregistered)
{
Invoke(*e);
}
}
Summary of the Pull Request
WSLCContainerImpllifecycle operations (Start, Stop, Delete) now coordinate through a sharedStateTransitionobject published underm_lock, replacing the per-operationm_stopNotification/m_destroyEventhandshake serialzied bym_stopLock; a second lifecycle request is no longer blocked behind an in-flightStopthat is waiting indefinitely (WSLC_STOP_TIMEOUT_NONE), and the fixed 60-second waits inStop()andDelete()are removed.DockerEventTrackernow invokes callbacks outside its own lock, fixing a lock inversion betweenWSLCContainerImpl::Execand event delivery that wedges the entire Docker event stream, and an iterator invalidation when a container stop unregisters its exec callbacks mid-dispatch.PR Checklist
Detailed Description of the Pull Request / Additional comments
Problem. Container lifecycle state was driven by two standalone events and a mutex.
Stop()acquiredm_stopLockthenm_lock, issued the Docker request, and waited up to60sonm_stopNotification.Event.OnEvent(Stop)acquiredm_stopLockwithstd::try_to_lockand returned early when aStopwas already in flight, deferring cleanup to that caller. Ownership of stop cleanup was therefore decided by a try-lock race, and becausem_stopLockis held for the entire duration of the wait, any second lifecycle request was blocked for as long as the firstStopwaited — unbounded forWSLC_STOP_TIMEOUT_NONE.Delete()and the auto-remove path waited onm_destroyEventunder the same fixed60scap, andOnEvent(Destroy)asserted!m_destroyEvent.is_signaled().OnEventwas notnoexcept, andContainerEvent::Startwas not handled at all, so a container started outside WSLC went unrecorded.DockerEventTracker::OnContainerEventheldm_lockacrosse.Callback(...), which produced two defects. First, a lock inversion:WSLCContainerImpl::Execholds the containerm_lockshared while constructingDockerExecProcessControl, whose constructor callsRegisterExecStateUpdatesand takes the tracker lock; the event thread took those in the opposite order, holding the tracker lock while callingWSLCContainerImpl::OnEvent, which takes the containerm_lockexclusive. Because the tracker is per-session, that deadlock stops delivery of all subsequent container and volume events. Second, iterator invalidation: the container'sdiecallback reachesReleaseProcesses(), which unregisters every live exec registration and erases from the samem_containerCallbacksvector the dispatch loop was iterating; the tracker'sstd::recursive_mutexallowed the erase to proceed and invalidate the loop's cached end iterator.Change. Lifecycle requests now publish a
StateTransitionand wait on it:StartTransitionpublishes into_Guarded_by_(m_lock) std::shared_ptr<StateTransition> m_transition;OnEventmatches the arriving event againstExpectedEventand callsCompleteTransition.WaitForConflictingTransitionToCompleteserializes conflicting operations while allowing a caller of the sameTransitionKindto attach to the in-flight transition rather than queue behind a mutex, which is what unblocks a concurrentKillor shorter-timeoutStop.WaitForTransitionCompletionwaits throughWSLCSession::CreateIOContext()and anEventHandle, so the wait is bounded by session termination instead of a hard-coded60s. Failures raised on the event thread are captured intoStateTransition::Exceptionand rethrown byAttachToTransitionon the requesting thread, soOnEventis nownoexcept.A second reader/writer lock,
m_lifecycleLock, is held shared by lifecycle requests until their transition is published and exclusive by event delivery, closing the window between issuing the Docker request and publishing the transition.StateTransition::Wrappercarries the deferredunique_com_disconnectsoDisconnect()runs on the COM caller after it leavesOnEvent's critical section rather than on the event thread, where it would block draining in-flight COM callers.OnStoppednow takes the exit code and, forWSLCContainerFlagsRm, upgrades the active transition'sExpectedEventtoContainerEvent::Destroyso auto-remove completes under the same transition instead of a second wait.StopNotification,m_destroyEvent, andm_stopLockare removed, andDeleteExclusiveLockHeldbecomesRequestDeleteExclusiveLockHeldsince resource release now happens on theDestroyevent.Event tracker locking notes.
OnContainerEventandOnVolumeEventcopy the matching registrations into a local vector underm_lock, release it, and invoke throughInvokeCallbacks. Registrations are stored asstd::shared_ptr<ContainerCallback>/std::shared_ptr<VolumeCallback>so a snapshot entry stays alive if it is unregistered mid-dispatch. Both derive from a newCallbackRegistrationbase holding astd::recursive_mutex InvokeLockand a_Guarded_by_(InvokeLock) bool Unregistered; the dispatch loop holdsInvokeLockwhile the callback runs and skips unregistered entries, andUnregisterCallbackerases underm_lock, releases it, then acquiresInvokeLockto block until any in-flight invocation completes. That preserves the previous guarantee that a callback never runs after itsEventTrackingReferenceis reset — required becauseDockerExecProcessControldeclaresm_eventTrackingReferencelast and so unregisters before its other members are destroyed.InvokeLockis recursive so a running callback can unregister itself. With no callback running underm_lock, it is now a leaf lock and is downgraded fromstd::recursive_mutextostd::mutex.ContainerEvent::Restartis added for the Dockerrestartaction.Lifecycle / state-machine impact.
Start()publishes aStarttransition and commitsWslcContainerStateRunningonly when the Dockerstartevent arrives; an unmatchedstartis logged asUnexpectedContainerStartinstead of being ignored.Stop()skips creating a transition onceWslcContainerStateExitedis already observed, and does not create one when the signal is notSIGKILLand cannot be expected to terminate the container.Delete()publishes aDeletetransition expectingContainerEvent::Destroy, which is also whereReleaseResources()and the COM disconnect now run, so aStopwith auto-remove and an explicitDeleteconverge on the same path.Validation Steps Performed
WSLCTests::ConcurrentContainerStopAndKill— guards against a second lifecycle request being blocked behindm_stopLockwhile an indefiniteStop(WSLC_STOP_TIMEOUT_NONE)is waiting; theKillmust reach Docker and return while theStopis still outstanding.WSLCTests::ConcurrentContainerStopTimeoutOverride— guards against a shorterStop(..., 0)being unable to override an in-flight indefiniteStop; both calls must complete.WSLCTests::ConcurrentContainerStopAndStart— assertsStartissued during an in-flightStopfails withWSLC_E_CONTAINER_IS_RUNNINGrather than racing the transition.WSLCTests::ConcurrentContainerStopAndForceDelete— asserts a forceDeleteduring an in-flightStopcompletes and both callers observeWslcContainerStateDeleted.WSLCTests::ForceDeleteAutoRemoveContainer— covers force-delete of aWSLCContainerFlagsRmcontainer, where the stop transition is upgraded to expectDestroy.WSLCTests::ExecContainerStopManyExecs— guards against the mid-dispatch iterator invalidation. Uses eight concurrent execs, because the pre-existingExecContainerDeleteuses a single exec and leaves nothing after the erased entry for the dispatch loop to walk into.WSLCTests::ExecContainerEventStress— guards against theExec/event-delivery lock inversion, and against a registration being destroyed while its own callback is in flight. Four threads run short-lived execs and release each process without waiting, so unregistration overlapsexec_diedispatch. A regression manifests as a hang rather than an assertion failure, since the deadlock is service-side and blocks the COM call itself.ConcurrentContainer*tests use aLaunchContainerWithBlockingStopHandlerhelper whose init process traps the stop signal and blocks on stdin, so the in-flightStopwindow is deterministic rather than timing-dependent.PluginTests.cppexpected log output addsWSLC Container stopping, session=*, id=*, reflecting thatOnContainerStoppingis now raised fromOnStopped.