wslc: Switch port relay AcceptThread from WaitForMultipleObjects to IO completion ports#40042
wslc: Switch port relay AcceptThread from WaitForMultipleObjects to IO completion ports#40042benhillis wants to merge 4 commits intofeature/wsl-for-appsfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the WSLC localhost port relay to use an I/O completion port (IOCP) for AcceptEx completions instead of WaitForMultipleObjects, removing the 64-handle wait limitation and enabling more than 63 concurrent port mappings.
Changes:
- Replace the accept loop with an IOCP-driven
AcceptThreadand associate each listen socket with a shared completion port. - Remove the relay’s per-port accept event and route both sync/async AcceptEx completions through IOCP.
- Update Windows tests to validate mapping/unmapping 100 ports instead of enforcing the prior 63-port limit.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/windows/wslrelay/localhost.cpp |
Switch accept scheduling/completions to IOCP; add stdin MessageReader to coordinate exit + message readiness. |
test/windows/WSLCTests.cpp |
Update port-mapping test to validate >63 mappings (now 100). |
OneBlue
left a comment
There was a problem hiding this comment.
I think this is a good idea, but given that the relay code is not our long term plan, I would recommend making this change at the relay::MultiHandleWait level so it will also solve the 64 handles issue in other places in the code (specifically the container IO thread, which could go over the limit if many Logs() / Exec() calls are made)
If we need to, we can then move the relay to use MultiHandleWait and match the rest of the code
7e40403 to
8370b5a
Compare
8370b5a to
87b2cd8
Compare
That's a good call, let me look at that. |
…letion ports Replace the WaitForMultipleObjects-based accept loop in the WSLC port relay with an IO completion port (IOCP). This removes the MAXIMUM_WAIT_OBJECTS (64) handle limit, allowing unlimited port mappings. Changes: - Associate each listen socket with a shared IOCP using the PortRelay pointer as the completion key. - Rewrite AcceptThread to use GetQueuedCompletionStatus instead of WaitForMultipleObjects. On shutdown, cancel all pending I/O and drain completions before returning. - Remove the AcceptEvent from PortRelay (no longer needed with IOCP). - Change ScheduleAccept from bool to void since both sync and async AcceptEx completions are now delivered through the IOCP. - Add a MessageReader that reads stdin on a dedicated thread, allowing the main loop to WaitForMultipleObjects on the exit event and message ready event simultaneously. - Remove the 63-port limit and update tests to validate 100 port mappings.
… AcceptThread - Add InheritHandle() for m_vmTerminatingEvent so the relay child process receives a valid exit event handle via PROC_THREAD_ATTRIBUTE_HANDLE_LIST. - Close parent pipe handles with .reset() instead of .release() so ReadFile in MapRelayPort detects relay process death via pipe EOF. - Move AcceptThread I/O drain into wil::scope_exit so pending accepts are always cancelled even if ScheduleAccept throws during initialization. - Wrap initial ScheduleAccept loop in try/catch to prevent a single port failure from killing the entire accept thread. - Track accept thread liveness with std::atomic<bool> to avoid posting stale IOCP exit signals when the thread has already exited. - Make PortRelay destructor cancel pending I/O instead of __fastfail. - Add <atomic> to precomp.h.
…IO completion ports Replace the WaitForMultipleObjects-based wait loop in relay::MultiHandleWait with an IO completion port (IOCP), removing the MAXIMUM_WAIT_OBJECTS (64) handle limit for all callers. Changes to relay::MultiHandleWait (relay.hpp/cpp): - Add unique_registered_wait RAII type for RegisterWaitForSingleObject handles - Add Register() pure virtual to OverlappedIOHandle, implemented by all subclasses - Handles that support IOCP (pipes, sockets) register directly; handles that don't (console handles, events) use a RegisterWaitForSingleObject bridge - Convert Run() to use GetQueuedCompletionStatus instead of WaitForMultipleObjects - Cancel() posts a key=0 completion to wake Run(); key=0 sets m_cancel on dequeue - Fix member order: m_iocp declared before m_handles for correct destruction Changes to port relay (localhost.cpp): - Replace AcceptThread's WaitForMultipleObjects with a direct IOCP: associate listen sockets via CreateIoCompletionPort, AcceptEx completions go straight to the IOCP, stop signal via PostQueuedCompletionStatus key=0 - Remove the MAXIMUM_WAIT_OBJECTS port limit check - Remove AcceptEvent from PortRelay (no longer needed with direct IOCP)
87b2cd8 to
6b9bbbb
Compare
| if (Iocp != nullptr && !WaitHandle) | ||
| { | ||
| THROW_IF_WIN32_BOOL_FALSE( | ||
| RegisterWaitForSingleObject(&WaitHandle, Handle.Get(), &EventHandle::WaitCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
There was a problem hiding this comment.
RegisterWaitForSingleObject expects a PHANDLE for the returned wait handle, but WaitHandle is a wil::unique_any wrapper. Passing &WaitHandle is not a valid output parameter type and should be replaced with the wrapper’s dedicated out-parameter helper (e.g., .put()/.addressof() depending on the WIL type) so this compiles and the previous registration is correctly overwritten.
| RegisterWaitForSingleObject(&WaitHandle, Handle.Get(), &EventHandle::WaitCallback, this, INFINITE, WT_EXECUTEONLYONCE)); | |
| RegisterWaitForSingleObject(WaitHandle.put(), Handle.Get(), &EventHandle::WaitCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
| if (!RegisteredWithIocp && Iocp != nullptr && !WaitBridge) | ||
| { | ||
| THROW_IF_WIN32_BOOL_FALSE(RegisterWaitForSingleObject( | ||
| &WaitBridge, Event.get(), &ReadHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
There was a problem hiding this comment.
RegisterWaitForSingleObject expects a PHANDLE output parameter, but WaitBridge is a wil::unique_any wrapper. Passing &WaitBridge is an invalid type for the API and will not compile reliably; use the wrapper’s out-parameter helper so the returned handle is stored correctly.
| &WaitBridge, Event.get(), &ReadHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); | |
| WaitBridge.put(), Event.get(), &ReadHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
| if (!RegisteredWithIocp && Iocp != nullptr && !WaitBridge) | ||
| { | ||
| THROW_IF_WIN32_BOOL_FALSE(RegisterWaitForSingleObject( | ||
| &WaitBridge, Event.get(), &SingleAcceptHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
There was a problem hiding this comment.
RegisterWaitForSingleObject expects a PHANDLE output parameter, but WaitBridge is a wil::unique_any wrapper. Passing &WaitBridge is the wrong type for this API; use the wrapper’s out-parameter helper to receive the registration handle.
| &WaitBridge, Event.get(), &SingleAcceptHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); | |
| WaitBridge.put(), Event.get(), &SingleAcceptHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
| if (!RegisteredWithIocp && Iocp != nullptr && !WaitBridge) | ||
| { | ||
| THROW_IF_WIN32_BOOL_FALSE(RegisterWaitForSingleObject( | ||
| &WaitBridge, Event.get(), &WriteHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
There was a problem hiding this comment.
RegisterWaitForSingleObject expects a PHANDLE output parameter, but WaitBridge is a wil::unique_any wrapper. Passing &WaitBridge is an invalid output argument type; use the wrapper’s out-parameter helper so the registration handle is captured correctly.
| &WaitBridge, Event.get(), &WriteHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); | |
| WaitBridge.put(), Event.get(), &WriteHandle::WaitBridgeCallback, this, INFINITE, WT_EXECUTEONLYONCE)); |
| // Try to associate the handle with the IOCP. Not all handle types support this | ||
| // (e.g., some socket types, console handles). Fall back to event-based mode on failure. | ||
| auto result = CreateIoCompletionPort(Handle.Get(), iocp, reinterpret_cast<ULONG_PTR>(completionTarget), 0); | ||
| if (result != nullptr) | ||
| { | ||
| SetFileCompletionNotificationModes(Handle.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | ||
|
|
||
| // Clear the event from OVERLAPPED — completions now go to the IOCP. | ||
| Overlapped.hEvent = nullptr; | ||
| RegisteredWithIocp = true; | ||
| } | ||
| // else: fall back to event-based mode (Overlapped.hEvent remains set) |
There was a problem hiding this comment.
After associating a handle with an IOCP, the code relies on FILE_SKIP_COMPLETION_PORT_ON_SUCCESS to avoid a completion packet being queued for synchronous I/O (which would later trigger Collect() while the handle is not in the pending state). SetFileCompletionNotificationModes can fail; currently its return value is ignored and RegisteredWithIocp is still set true. Handle the failure explicitly (e.g., throw, or fall back to event-based mode and keep Overlapped.hEvent intact) to avoid double-completion/incorrect state transitions.
| // Try to associate the handle with the IOCP. Not all handle types support this | |
| // (e.g., some socket types, console handles). Fall back to event-based mode on failure. | |
| auto result = CreateIoCompletionPort(Handle.Get(), iocp, reinterpret_cast<ULONG_PTR>(completionTarget), 0); | |
| if (result != nullptr) | |
| { | |
| SetFileCompletionNotificationModes(Handle.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| // Clear the event from OVERLAPPED — completions now go to the IOCP. | |
| Overlapped.hEvent = nullptr; | |
| RegisteredWithIocp = true; | |
| } | |
| // else: fall back to event-based mode (Overlapped.hEvent remains set) | |
| // | |
| // First, attempt to configure the handle so that successful synchronous I/O | |
| // does not queue a completion to the IOCP. If this fails, continue using | |
| // event-based completion (Overlapped.hEvent remains set) and do not | |
| // associate the handle with the IOCP. | |
| // | |
| if (!SetFileCompletionNotificationModes(Handle.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)) | |
| { | |
| // Fall back to event-based mode on failure. | |
| // Overlapped.hEvent is left intact and RegisteredWithIocp stays false. | |
| LOG_LAST_ERROR(); | |
| return; | |
| } | |
| // Try to associate the handle with the IOCP. Not all handle types support this | |
| // (e.g., some socket types, console handles). Fall back to event-based mode on failure. | |
| auto result = CreateIoCompletionPort(Handle.Get(), iocp, reinterpret_cast<ULONG_PTR>(completionTarget), 0); | |
| if (result != nullptr) | |
| { | |
| // Clear the event from OVERLAPPED — completions now go to the IOCP. | |
| Overlapped.hEvent = nullptr; | |
| RegisteredWithIocp = true; | |
| } | |
| // else: fall back to event-based mode (Overlapped.hEvent remains set, mode flag is harmless) |
| SetFileCompletionNotificationModes(ListenSocket.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | ||
| Overlapped.hEvent = nullptr; | ||
| RegisteredWithIocp = true; |
There was a problem hiding this comment.
Same issue as other IOCP registrations: SetFileCompletionNotificationModes(..., FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) return value is ignored even though the state machine assumes it succeeded (otherwise synchronous AcceptEx could still queue an IOCP packet and lead to double-processing / Collect() assertions). Consider checking the call and falling back to event-bridge mode (or failing the registration) if it isn’t supported.
| SetFileCompletionNotificationModes(ListenSocket.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| Overlapped.hEvent = nullptr; | |
| RegisteredWithIocp = true; | |
| BOOL notificationModesSet = SetFileCompletionNotificationModes(ListenSocket.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| LOG_LAST_ERROR_IF(!notificationModesSet); | |
| if (notificationModesSet) | |
| { | |
| Overlapped.hEvent = nullptr; | |
| RegisteredWithIocp = true; | |
| } |
| auto result = CreateIoCompletionPort(Handle.Get(), iocp, reinterpret_cast<ULONG_PTR>(completionTarget), 0); | ||
| if (result != nullptr) | ||
| { | ||
| SetFileCompletionNotificationModes(Handle.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); |
There was a problem hiding this comment.
Same issue as other IOCP registrations: SetFileCompletionNotificationModes(..., FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) is unchecked even though the code assumes synchronous I/O will not queue a completion packet. If this call fails, you can get a queued completion for an operation already handled synchronously, causing incorrect state/Collect() assertions. Handle failure (throw or fall back to event mode) before setting RegisteredWithIocp = true.
| SetFileCompletionNotificationModes(Handle.Get(), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| THROW_IF_WIN32_BOOL_FALSE(SetFileCompletionNotificationModes( | |
| Handle.Get(), | |
| FILE_SKIP_COMPLETION_PORT_ON_SUCCESS)); |
| // Find the top-level handle in m_handles that matches the completion target. | ||
| auto* completionTarget = reinterpret_cast<OverlappedIOHandle*>(completionKey); | ||
|
|
||
| auto it = std::ranges::find_if( | ||
| m_handles, [completionTarget](const auto& entry) { return entry.second.get() == completionTarget; }); | ||
|
|
||
| if (it == m_handles.end()) | ||
| { | ||
| // Handle was already removed (e.g., by CancelOnCompleted). Discard the completion. | ||
| continue; | ||
| } |
There was a problem hiding this comment.
MultiHandleWait::Run does a linear search through m_handles for every IOCP completion to map completionKey back to the owning handle. Since this change explicitly removes the 64-handle cap, this can become an O(n) bottleneck with larger handle sets. Consider keeping an auxiliary map from OverlappedIOHandle* (completion key) to index/iterator (and updating it on erase) to make completion dispatch O(1).
| THROW_LAST_ERROR_IF_NULL(CreateIoCompletionPort( | ||
| reinterpret_cast<HANDLE>(e.second->ListenSocket.get()), iocp.get(), reinterpret_cast<ULONG_PTR>(e.second.get()), 0)); | ||
|
|
||
| SetFileCompletionNotificationModes(reinterpret_cast<HANDLE>(e.second->ListenSocket.get()), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); |
There was a problem hiding this comment.
SetFileCompletionNotificationModes(..., FILE_SKIP_COMPLETION_PORT_ON_SUCCESS) return value is ignored, but the accept loop assumes synchronous AcceptEx completions won’t also enqueue an IOCP packet (otherwise you risk double-processing an accept). Please check/handle failure explicitly (throw, or avoid relying on skip-on-success when it isn’t supported) before proceeding.
| SetFileCompletionNotificationModes(reinterpret_cast<HANDLE>(e.second->ListenSocket.get()), FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| BOOL completionModesSet = SetFileCompletionNotificationModes( | |
| reinterpret_cast<HANDLE>(e.second->ListenSocket.get()), | |
| FILE_SKIP_COMPLETION_PORT_ON_SUCCESS); | |
| THROW_LAST_ERROR_IF(!completionModesSet); |
Replace the WaitForMultipleObjects-based wait loop in
relay::MultiHandleWaitwith an IO completion port (IOCP), removing theMAXIMUM_WAIT_OBJECTS(64) handle limit for all callers (IORelay, container IO, socket accept, etc.).Switch the port relay
AcceptThreadfromWaitForMultipleObjectsto a direct IOCP, removing the 63-port mapping limit.relay::MultiHandleWait changes (relay.hpp/cpp)
unique_registered_waitRAII type forRegisterWaitForSingleObjecthandlesRegister()pure virtual toOverlappedIOHandle, implemented by all subclassesRegisterWaitForSingleObjectbridgeRun()to useGetQueuedCompletionStatusinstead ofWaitForMultipleObjectsCancel()posts a key=0 completion to wakeRun(); key=0 setsm_cancelon dequeuem_iocpdeclared beforem_handlesfor correct destructionPort relay changes (localhost.cpp)
CreateIoCompletionPortAcceptExcompletions go straight to the IOCP — no events or thread pool waitsPostQueuedCompletionStatuskey=0AcceptEventfromPortRelay(no longer needed)MAXIMUM_WAIT_OBJECTSport limit check