Repository navigation
Description
Windows Version
Microsoft Windows [Version 10.0.26200.8037]
WSL Version
2.7.10.0
Are you using WSL 1 or WSL 2?
- WSL 2
- WSL 1
Kernel Version
6.18.33.2-microsoft-standard-WSL2
Distro Version
Ubuntu 24.04.1 LTS
Other Software
Windows Terminal (the affected wsl.exe was the relay for a single long-lived
tab). No third-party software is implicated — the defect is in WSL's own
interop path and is reachable from any terminal.
Related issues
#40651 reports the same symptom — wsl.exe spinning at 100% because a
ReadFile loop has no bytesRead == 0 branch on the synchronous-success path.
It is closed, but not resolved: the microsoft-github-policy-service bot
auto-closed it after seven days of author inactivity, with the note "If you're
still experiencing this issue please re-file it as a new issue." This report is
that re-file, and it differs in three ways that I think matter:
- Different file. wsl.exe relay loops in
src/windows/common/relay.cppmissbytesRead == 0EOF check on synchronous-success path; causewsl.exeto spin at 100% on AFD sockets #40651 points atsrc/windows/common/relay.cpp. A
maintainer replied
thatScopedMultiRelay"is indeed missing the read length check. But it's
only used in set version. So it's likely not the cause of the busy loop you
are seeing." I agree — the loop in this report is
ProcessInteropMessagesinsrc/windows/common/interop.cpp, which is a
different function with a different exit structure. - The function is pinned. The original reporter noted "We cannot pin the
spin to one specific function." Here the stack, the thread name, and the
handle all resolve to a single loop, detailed below. - The requested dumps exist. The same comment asked for "several dumps
seconds apart withprocdump -ma <pid>". I captured three, 20 seconds
apart, and the zero-byte read is measured in them rather than inferred.
Repro Steps
I do not have a deterministic repro. The trigger is a narrow race, but the
race itself is specific and reviewable, so I've described it precisely rather
than guessing at steps.
What produced it in practice:
- Open a WSL2 session in a terminal and leave it open for days
- Use it normally — in particular, run Windows binaries from inside Linux
(explorer.exe .,cmd.exe /c …,git.exe,code,clip.exe, anything
routed through theWSLInteropbinfmt handler) - Watch
wsl.exethread count and CPU over time
Each interop invocation spawns one "Interop" thread. The overwhelming
majority exit normally. Roughly once per 20 hours of ordinary use, one
enters an unterminating loop and never exits. They accumulate; each burns
~100% of one core permanently.
The race that has to be lost. Normal shutdown does not trip this:
- The launched Windows process exits →
WaitForMultipleObjectswakes on the
process handle (interop.cpp:467) → loop breaks - Windows sends
LX_INIT_PROCESS_EXIT_STATUS(interop.cpp:338) - Linux receives it (
binfmt.cpp:320), returns at:398, and its
wil::unique_fddestructors close all four sockets
Step 1 runs on the async branch, so the happy path works. The bug needs
that order inverted — the Linux side closing Sockets[3] first, in the window
between one Windows-side read completing and the next being issued:
- Windows already parked in the async wait → EOF arrives via
GetOverlappedResult→BytesRead == 0→ handled correctly - Windows issues
ReadFileafter the FIN has already landed → completes
synchronously, success, 0 bytes → spins forever
The losing window is a handful of instructions wide, which is why the rate is
~1 per 20 h rather than one per invocation. SIGINT shares the signal mask
with SIGWINCH (binfmt.cpp:206), so a Ctrl-C'd interop command looks like a
plausible way to get the Linux side closing first.
Forcing it deterministically should be possible by delaying the
Windows-side re-read, or closing the Linux-side control socket on a timer, so
the FIN reliably lands before the next ReadFile.
Expected Behavior
When the interop control socket reaches EOF, ProcessInteropMessages should
break out of its read loop, the "Interop" thread should exit, and wsl.exe
should return to ~0% CPU.
Actual Behavior
The thread never exits. It busy-reads a closed socket at ~100% of one core for
the entire remaining lifetime of the wsl.exe process. Leaked threads
accumulate with no upper bound.
On my machine a single wsl.exe reached 68.5% of a 12-core system (821% of
one core) across 10 leaked threads, having consumed 849 CPU-hours in 9
days. Left running it would have saturated all 12 cores. Killing the process
was the only remedy.
The defect
ProcessInteropMessages (src/windows/common/interop.cpp:422) has an
infinite read loop whose body handles only the cases where ReadFile
returns FALSE. When ReadFile completes synchronously and returns TRUE,
the loop takes no action and immediately re-issues the read:
// src/windows/common/interop.cpp:422
DWORD ExitCode = 1;
for (;;)
{
DWORD BytesRead;
LX_INIT_WINDOW_SIZE_CHANGED WindowSizeMessage;
bool Success = ReadFile(MessageHandle, &WindowSizeMessage, sizeof(WindowSizeMessage), &BytesRead, &Overlapped);
if (!Success)
{
const auto LastError = GetLastError();
if ((LastError == ERROR_BROKEN_PIPE) || (LastError == ERROR_HANDLE_EOF))
{
/* ... break ... */
}
THROW_LAST_ERROR_IF(LastError != ERROR_IO_PENDING);
/* async path: waits, then correctly checks (!Success) || (BytesRead == 0) -> break */
}
// <- line 480: no `else`. Synchronous success falls through and re-reads.
}The asymmetry is the bug. The asynchronous completion path correctly
treats BytesRead == 0 as EOF and breaks:
Success = GetOverlappedResult(MessageHandle, &Overlapped, &BytesRead, FALSE);
CancelIo.release();
if ((!Success) || (BytesRead == 0)) // <-- EOF handled here
{
...
break;
}The synchronous completion path performs no equivalent check.
All three loop exits sit inside if (!Success), so once the socket starts
reporting EOF-as-success the loop is not merely leaky but provably
non-terminating:
| Exit | Line | Requires |
|---|---|---|
ERROR_BROKEN_PIPE / ERROR_HANDLE_EOF |
430 | Success == FALSE — a socket produces neither |
BytesRead == 0 |
452 | Success == FALSE first (async branch) |
| child process exited | 467 | Success == FALSE first (async branch) |
The third is worth calling out: the WaitForMultipleObjects on
Result->Process is also unreachable, so not even the launched Windows
process exiting can break the loop.
The comment above the loop (line 416) suggests how this happened:
"Read messages from the message handle. Break out of the loop if the pipe
is connection is closed or the process exits."
That is accurate for the WSL1 path, which does pass a real named pipe. The
loop appears to have been reused for the socket path without revisiting EOF
semantics.
Why WSL2 hits this and WSL1 does not
The two call sites pass different kinds of handle:
-
WSL1 / LxBus path —
interop.cpp:559passesSignalPipe.first.get(),
a real named pipe (OpenAnonymousPipe→NtCreateNamedPipeFile). A closed
pipe yieldsERROR_BROKEN_PIPE, which is handled. No spin. -
WSL2 / VM path —
interop.cpp:335passes a socket cast to a handle:ExitStatus.ExitCode = ProcessInteropMessages(reinterpret_cast<HANDLE>(Sockets[3].get()), &Result);
A graceful socket close is EOF-as-success, so neither handled status is
ever produced, and the loop spins.
Which socket: a Hyper-V socket (AF_HYPERV on Windows, AF_VSOCK on
Linux), created overlapped at hvsocket.cpp:102. Linux opens it first —
binfmt.cpp:153 allocates a fresh listening vsock on an ephemeral port per
interop invocation and passes the port number up in the create-process message
(Message->Port = SocketAddress.svm_port, :161). Windows connects four
times (interop.cpp:283), Linux accepts four (binfmt.cpp:184) — stdin,
stdout, stderr, and the control channel. It is the control channel,
Sockets[3], that ProcessInteropMessages reads, and its peer is the /init
binfmt interpreter inside the utility VM.
This is distinct from the long-lived interop server channel
(UtilConnectToInteropServer(), binfmt.cpp:167), which only carries the
launch request.
Secondary defect (same missing branch)
A window-resize message that completes synchronously is silently discarded —
ResizePseudoConsole is only reached on the asynchronous path. So a resize
delivered inline is dropped rather than applied.
Suggested fix
Handle synchronous completion symmetrically with asynchronous completion:
bool Success = ReadFile(MessageHandle, &WindowSizeMessage, sizeof(WindowSizeMessage), &BytesRead, &Overlapped);
if (!Success)
{
/* ... unchanged ... */
}
else if (BytesRead == 0)
{
// Peer closed gracefully. On a socket this surfaces as success with zero
// bytes rather than ERROR_BROKEN_PIPE.
if (WI_IsFlagClear(Result->Flags, LX_INIT_CREATE_PROCESS_RESULT_FLAG_GUI_APPLICATION))
{
THROW_IF_WIN32_BOOL_FALSE(TerminateProcess(Result->Process.get(), 1));
}
break;
}
else
{
// Synchronous completion with data: currently dropped.
WI_ASSERT((BytesRead == sizeof(WindowSizeMessage)) && (WindowSizeMessage.Header.MessageType == LxInitMessageWindowSizeChanged));
const COORD Size{static_cast<SHORT>(WindowSizeMessage.Columns), static_cast<SHORT>(WindowSizeMessage.Rows)};
THROW_IF_FAILED(ResizePseudoConsole(Result->PseudoConsole.get(), Size));
}The EOF-handling and resize-handling blocks are now duplicated between the
synchronous and asynchronous paths; factoring each into a small lambda would
be cleaner. Happy to send this as a PR if the approach looks right — I have
not built WSL locally, so it is untested beyond review.
A defensive backstop worth considering separately: these interop threads have
no upper bound and no liveness check, so any future non-terminating condition
in this loop degrades the whole machine silently. A leaked thread is permanent
for the life of the process.
Note on current sources
ProcessInteropMessages is byte-identical between tag 2.7.10 and master
at the time of writing, so this is not fixed in current sources. Release
2.7.11 contains only a MoveDistribution VHD fix and security backports.
Diagnostic Logs
I did not collect collect-wsl-logs.ps1 output — the failure is a CPU spin
with no error path, so nothing is logged. Instead I captured three
full-memory dumps of the affected wsl.exe, 20 seconds apart, and analysed
them in WinDbg.
Identical stack signature (matching hash across all three dumps) on all
ten runaway threads:
ntdll!NtReadFile+0x14
KERNELBASE!ReadFile+0x12a
wsl+0x113c51
wsl+0x111fcf
wsl+0x110bc2
wsl+0x1b35df
kernel32!BaseThreadInitThunk+0x17
ntdll!RtlUserThreadStart+0x2c
All ten threads are named "Interop" — set at interop.cpp:271, inside
the very std::thread that calls ProcessInteropMessages. The four wsl
frames map one-to-one onto the source:
| Frame | Source |
|---|---|
wsl+0x1b35df |
std::thread trampoline (interop.cpp:268) |
wsl+0x110bc2 |
lambda body (interop.cpp:271–340) |
wsl+0x111fcf |
ProcessInteropMessages (interop.cpp:409) |
wsl+0x113c51 |
the ReadFile call (interop.cpp:426) |
Each thread holds a distinct handle, consistent with one leaked thread per
interop session. Windows reports every one as Running with no wait
reason — they never block, which is the signature of a busy loop rather
than a stalled wait.
The zero-byte read, observed. wsl.exe ships without public symbols, so
BytesRead is not recoverable by name — but ReadFile deposits the byte
count in the OVERLAPPED on the thread stack, which is reachable:
ntdll!NtReadFilebeginsmov r10,rcxandr10survives the syscall,
giving theHANDLEdirectly (0x428,0x748,0x710)rdxholds theEventparameter (0x66c);r9holdsApcContext, which
KERNELBASE!ReadFilesets to theOVERLAPPEDpointer (0xb5c8fff858)- reading 32 bytes at that address returns an
OVERLAPPEDwhosehEventis
0x66c, matchingrdx— confirming the right structure
Internal is the NTSTATUS, InternalHigh the byte count. Across the three
captures of the same thread:
| Dump | Internal |
InternalHigh |
|---|---|---|
| 1 | 0x103 STATUS_PENDING |
0 |
| 2 | 0x103 STATUS_PENDING |
0 |
| 3 | 0x0 STATUS_SUCCESS |
0 |
Two samples caught the IRP in flight; the third caught it completed —
success with zero bytes. That is the EOF-as-success case the synchronous
path has no branch for, measured rather than inferred.
One further oddity, offered as an observation rather than a claim:
PointerCount on these three handles ranges 33,764–65,423 and fluctuates
between captures, where ordinary file objects sit in the low tens. I could not
determine the mechanism from a user-mode dump, but it is a consistent
fingerprint of extreme I/O churn on those handles.
Per-thread CPU, showing each thread burning ~85–92% of a core
continuously from the moment it was created, and never exiting:
| Thread created | CPU hours | % of one core since creation |
|---|---|---|
| day 0 | 189.6 | ~92% |
| day 3 | 125.8 | ~90% |
| day 3 | 124.0 | ~89% |
| day 4 | 103.3 | ~88% |
| day 5 | 85.8 | ~89% |
| day 6 | 65.9 | ~86% |
| day 6 | 60.5 | ~83% |
| day 6 | 59.1 | ~83% |
| day 8 | 18.7 | ~87% |
| day 8 | 17.0 | ~79% |
These sum to the process's entire CPU total, so the loop accounts for 100% of
the consumption.
I still have the three full-memory dumps. I have not attached them because
they are ~72 MB each and, being full-memory captures of a process that relayed
terminal I/O for nine days, may contain environment variables, command lines,
and buffered stdio. Happy to send them to wsl-gh-logs@microsoft.com if
that would help — with private PDBs they should resolve wsl+0x113c51 to an
exact line and let you read BytesRead directly.
Activity
github-actions commentedon Jul 25, 2026
Logs are required for review from WSL team
If this a feature request, please reply with '/feature'. If this is a question, reply with '/question'.
Otherwise, please attach logs by following the instructions below, your issue will not be reviewed unless they are added. These logs will help us understand what is going on in your machine.
How to collect WSL logs
Download and execute collect-wsl-logs.ps1 in an administrative powershell prompt:
The script will output the path of the log file once done.
If this is a networking issue, please use
.\collect-wsl-logs.ps1 -LogProfile networkinginstead, following the instructions in Collect WSL logs for networking issuesOnce completed please upload the output files to this GitHub issue.
See Collect WSL logs (recommended method).
If you choose to email these logs instead of attaching them to the bug, please send them to wsl-gh-logs@microsoft.com with the GitHub issue number in the subject, and include a link to your GitHub issue comment in the message body, and reply with '/emailed-logs'.
racterub commentedon Jul 25, 2026
/emailed-logs
Sent
wsl41173-dumps.7z(18.4 MB) to wsl-gh-logs@microsoft.com with41173in the subject line.It contains three full-memory dumps of the affected
wsl.exe(PID 4840), captured 22 seconds apart while the busy loop was active:wsl4840-1.dmpwsl4840-2.dmpwsl4840-3.dmpThis is the "several dumps seconds apart" that was requested on #40651 and never supplied.
The frame of interest is in dump 3. The
OVERLAPPEDbelonging to a spinningInteropthread'sReadFilereadsInternal = STATUS_SUCCESS,InternalHigh = 0, where dumps 1 and 2 showSTATUS_PENDING/ 0 for the same thread. That is EOF on theAF_HYPERVsocket surfacing as synchronous success with zero bytes — the one completion shape the loop body inProcessInteropMessagesdoesn't act on, so it re-issues the read immediately and forever.wsl.exeships without public symbols, so for anyone reproducing the read from the dumps:NtReadFilepreserves the handle inr10across the syscall, andr9holdsApcContext, whichKERNELBASE!ReadFilesets to theOVERLAPPEDpointer. Recovering it from theMemory64ListStreamyields anhEventmatchingrdx, confirming the structure. With private PDBs this should be considerably more direct.No
collect-wsl-logs.ps1ETW trace is included, deliberately. The process had to be terminated to recover the machine before a trace could be started, and the wedge itself is a narrow race that can't be reproduced on demand — so a capture taken now would only record a healthy system. If an HvSocket trace of the wedge would still be valuable, I'm happy to arm-LogProfile hvsocketand wait for the next occurrence; it recurs roughly once per 20 hours of normal interop use on this machine.github-actions commentedon Jul 25, 2026
Diagnostic information
Resolve GitHub credentials with gh instead of the Windows manager
Convert clipimg to WSLg, dropping its powershell.exe interop
Remove play-sound's powershell.exe fallback
Stop routing WSL git credentials through the manager .exe
dario-spagnolo commentedon Aug 1, 2026
I hit this on a production workstation before finding this issue, and instrumented it for 13.5 hours at 30-second resolution (1588 samples, no gap longer than 2 minutes). I arrived at the interop path empirically, from the outside, without having read the source. The behavioural evidence below independently supports the
interop.cppdiagnosis in this report over therelay.cppattribution in #40651.Everything below is measured unless explicitly flagged as inference.
Environment
Why this data points at the interop control socket specifically
Four independent observations, each of which the interop hypothesis predicts and a generic relay-EOF hypothesis does not:
wslhost.exeis affected, not justwsl.exe. Awslhost.exe --distro-id {...} --vm-id {...}process accumulated 4 threads pinned at 100% and 26.5 hours of CPU over a 24-hour VM uptime.sshdoverlocalhost:22instead ofwsl.execreates no session relay and, crucially, no interop socket. Zero accumulation, verified over three consecutive trigger cycles.wsl.exe -e <cmd>invocations leak nothing. Controlled test: six sequential calls from a fresh console produced zero persistent relay processes and zero spinning threads.Taken together these say: the leak requires a live interop channel. That is exactly what
ProcessInteropMessagessits on.The accumulation is exactly linear
Every hit adds +6 threads, +34 handles, and +2 permanently spinning threads. Each spinning thread sits at ~100% of a core,
ThreadState = Running, noWaitReason. Closed form:Measured on one interactive session relay (
wsl.exe, command line"C:\WINDOWS\system32\wsl.exe", no arguments), from a freshly booted VM:Handle counts form a near-perfect arithmetic progression: 239, 269, 307, 341, 371, 405, 439, 473, 507, 541.
Note on saturation: past roughly n=6 the CPU percentage stops tracking the model — not because the leak slows, but because the host runs out of cores (total load was 89.2% at n=8). Thread count remains monotonic and is the reliable metric; CPU percentage is not, once saturated. This matters for anyone trying to reproduce and measure.
Also worth noting: for the first 31 minutes this session sat at 4 threads and 0.0–0.2% CPU while fully attached and interactive. An idle attached session costs nothing — the accumulation is entirely event-driven.
The trigger is periodic — every 30 minutes
Intervals between the 10 steps above:
A single 30-minute clock, with 2 of 12 slots producing nothing — roughly an 83% hit rate. I could not identify what fires (see below), but the regularity means this is reproducible on a schedule rather than by user action, which should make it tractable to instrument.
Orphaned
wslhost.exekeeps accumulating after its session diesWhen the session relay was killed, the
wslhost.exeit had spawned survived as an orphan — itsParentProcessIdpointing at a dead PID — and kept taking hits on the same 30-minute schedule for three more hours:Six consecutive hits, six successes. It reached 9.8 of 16 cores (61.4% of the machine) on its own, three hours after the session it served had been closed. Killing it dropped host load from 61.4% to 3.1% instantly, with no effect on the running VM — distro, systemd services, Docker containers and an attached tmux session all survived untouched.
Operationally this is nasty: closing the terminal window looks like cleanup but isn't, and the orphan is invisible unless you check
ParentProcessId.No pre-existing session ⇒ no effect at all
After killing the orphan I left the machine with no interactive session, reaching the distro over SSH instead. Three consecutive 30-minute cycles passed with zero effect — not a single thread created across all
wsl.exe/wslhost.exe/wslrelay.exe/wslservice.exe/wsl-gvproxy.exeprocesses, at 0.00% CPU:Given the ~83% hit rate, three clean cycles by chance is under 1%.
User-visible impact
The spin starves the interop bridge. Round-trip for a trivial
wsl.exe -d <distro> -e cat /proc/loadavg, sampled every 5 minutes throughout the day:This is what the defect feels like from a user's chair: WSL sessions that get slower and slower over a working day, while everything inside the distro looks perfectly healthy —
vmmemWSLat 0% CPU, guest/proc/pressure/cpuatavg10=0.00, load average below 1. Every guest-side diagnostic exonerates the guest, which is why this is so hard to attribute.Workaround
Reach the distro over SSH rather than
wsl.exe. A TCP connection tosshdcreates no interop socket. Verified: 12 relay processes before connecting, 12 after, and confirmed by the three clean cycles above.Caveats for anyone adopting it: an SSH session has no
WSL_INTEROP(executing Windows binaries fails), no Windows entries inPATH, and no WSLg environment.DISPLAY=:0restores WSLg (verified). Interop can be restored by borrowing a socket from/run/WSL/*_interop, but those belong to the VM-level host process, which is itself affected and only cleared bywsl --shutdown— so that trade defeats the purpose. SSH also cannot start a stopped VM.Otherwise: killing the affected process reclaims the cores immediately and cleanly (the spin does not return until the next hit), and
wsl --shutdownclears the VM-levelwslhost.exe. Restarting individual components does not help — I tested restarting the networking helper running under the affected host process: 398.8% before, 399.3% after.What I could not determine
What fires every 30 minutes. I ran five targeted capture windows; three of them coincided with a step (13:10:23, 14:10:25 and 15:10:55). The first two sampled the guest every 2 s; the third scanned
/procevery 200 ms, which is the only one fine enough to catch sub-second processes. In all three, at the moment of the step:/init /mnt/c/...) — the set was byte-identical across every window;So the trigger is not a program launch — at least not one that survives 200 ms — and looks like an already-running process opening and closing an interop connection. Candidates I chased and eliminated: a scheduled task that coincided with exactly one step out of ten, the networking helper, and Docker health checks (30-second period, cannot produce a 30-minute one).
One detail that may help localise the failing branch: I twice observed a clean transient on the affected process — +3 threads / +15 handles, fully released within 30 seconds, no spinning threads left behind. The nominal close path works. Only a specific case leaks, consistent with the synchronous-success-with-zero-bytes branch identified in this report.
On PR #41205, and a cheap validation protocol
I see #41205 implemented the symmetric synchronous-EOF handler and was self-closed by its author with "pushed without testing". If validation is the only thing blocking it, the good news is that this defect is unusually cheap to validate, because the effect is binary rather than a matter of degree.
To be clear about my own limits first: I am on a managed corporate workstation and cannot install unofficial or self-built WSL binaries, so I am not in a position to test a patched build myself. What I can offer is the protocol, derived from the measurements above, for whoever can:
wsl.exesession and leave it attached and idle. On this machine the trigger fires every 30 minutes with an ~83% hit rate; two hours yields three or four hits.Threads.Counton the session'swsl.exe(and on anywslhost.exe --distro-id). Each hit is exactly +6 threads, +34 handles, and +2 threads pinned at ~100% withThreadState = Runningand noWaitReason. Do not use CPU percentage as the primary metric — past ~6 spinning threads it saturates and understates.The whole thing is an hour or two of wall-clock with no special tooling —
Get-Processand aStart-Sleeploop are sufficient.Offer
I have the full timestamped telemetry: per-process CPU, thread counts and handle counts at 30-second resolution over 13.5 hours (1588 samples, no gap over 2 minutes), 162 bridge-latency probes across the same period, and five targeted capture windows of ~4 minutes each — four sampling the guest at 2 s, one at 200 ms — of which three coincided with a step. Happy to share any slice of it.
I also still have a machine that reproduces this reliably on the official 2.7.10.0 build, so I can run further observation on request — additional counters, longer windows, different session configurations — as long as it does not require installing a modified binary. And once a fix ships in an official release, I am well placed to confirm it in the field: the repro is deterministic and the metric is unambiguous.
JackNorris commentedon Aug 2, 2026
I hit this back on 2.7.11, before finding the issue. A
wsl.exeserving a fiveday old Windows Terminal tab had accumulated 18 permanently spinning threads and
about 1,250 CPU-hours, burning 17.5 of 32 logical processors with the distro
completely idle.
Reading the thread, Dario Spagnolo (@dario-spagnolo) noted
they can't install a self-built binary on a managed workstation. I can, so I
built
master(e7197ca) with and without #41205 and measured it.1200 rounds of a pty-based repro per arm. Both MSIs came from the same tree and
toolset, so only the 18 lines differ. The unpatched self-build leaked the same
count as the shipped binary, so the build reproduces the defect faithfully.
Their warning about CPU% holds up: 32 leaked threads on a 32-CPU host measured as
27.3 cores, understating by five. Thread count stayed exact.
#41205 was self-closed for want of a Windows build environment. As far as I can
tell that was the only thing missing from it.
Repro script available if it's useful. It follows the route racterub (@racterub) suggested,
closing the Linux side inside the re-issue window, with the addition that it
needs a pty and forced
SIGWINCHchurn. That's why a plainwsl.exe -e <cmd>loop never triggers this.
x64 Release only; I didn't run the test suite.
a1243924018 commentedon Aug 13, 2026
Thanks for the detailed diagnosis and the memory dumps. Confirming this is still biting us on long-lived interactive WSL2 sessions — the same 100%-CPU
ProcessInteropMessagesspin onwsl.exe/wslhost.exeinterop threads after a terminal tab has been open for days.The fix already exists as #41205, and JackNorris (@JackNorris)'s measurement above is the proof it works: self-built
master+ #41205 = 0 leaked spinning threads over 1200 pty repro rounds, vs 32 leaked threads / 31.3 cores on official 2.9.4. The only reason #41205 was closed is that its author had no Windows build environment ("Closing — pushed without testing") — not that the patch is wrong.The diff is 18 lines in
src/windows/common/interop.cpp: it adds the missingBytesRead == 0(EOF) branch on the synchronousReadFilesuccess path, mirroring the identical check that already exists on the async path. Clean, minimal, and independently verified in this thread.Could a maintainer please take a look at #41205 and consider re-opening it for CI / merge? This is a real defect reachable from any terminal — every long-lived interactive WSL session eventually leaks spinning interop threads that burn a full core doing nothing.
ccl100 commentedon Nov 2, 2026
Another data point: WSL 2.6.3, and the leaking process is the per-distro interop server (
wslhost.exe --vm-id … --handle …, no--distro-id)Environment: Windows 10.0.26200.9711 · WSL 2.6.3.0 · kernel 6.6.87.2-1 · WSLg 1.0.71 · Ubuntu 24.04.3 with systemd · 32 logical processors · distro up for 17 days with many long-lived interactive sessions.
Observation. I took no dumps, so this comes from behaviour plus reading the source:
wslhost.exewith the command line--vm-id {…} --handle 3184uses ~1.9 cores permanently. Its parent iswslservice.exe, and it was started together with the distro. About 90 % of that is kernel time, at ~1.8 M I/O operations/s with 0 bytes transferred.Interopthreads spinning inProcessInteropMessages. Because the synchronous path never looks at the process handle, they keep spinning long after the launched Windows process has exited.ProcessInteropMessages, a synchronousReadFilesuccess withBytesRead == 0falls through and the read is re-issued. So the affected range goes back at least to 2.6.3.What's different from the reports above
Process type. This isn't a session relay (
wsl.exe) or a session-takeoverwslhost.exe --distro-id …. It's the distro's own interop server, which the service starts inWslCoreInstance.cpp("Launch the interop server with the user's token") viaLaunchInteropServer(nullptr, socket, nullptr, nullptr, &m_runtimeId, …). It handles requests throughVmModeWorkerThread→CreateProcessVmMode, which starts oneInteropthread per request →ProcessInteropMessages.Why the leaks pile up here. If a process's
WSL_INTEROPsocket no longer exists (or the variable is unset) and its ancestry reaches PID 1, interop falls back to/run/WSL/1_interop→2_interop, i.e. this wslhost. Long-lived setups produce a lot of such processes. Here ~250 processes still hadWSL_INTEROP=/run/WSL/<pid>_interopset, but that socket was gone, presumably because thewsl.exesession that started their session host had ended. Every Windows binary those processes launch goes through this one wslhost.A deterministic way to route a call through it (handy for reproducing on this process type):
On the Windows side, the parent of the spawned
cmd.exeis the--vm-id … --handle …wslhost.Killing it isn't a free workaround. This wslhost serves every session-less process of the distro. As far as I can tell,
WslCoreInstancestarts it once during instance initialization, and I found no respawn path. Killing it should therefore leave those processes without interop until the distro restarts. I deliberately didn't test that. On this machine it would breakpowershell.exe/cmd.exe/clip.exefor most of the long-running sessions, so the only options are to accept the burned cores or restart WSL.+1 for merging #41205 (or an equivalent EOF + process-handle check on the synchronous path). All three host process types go through the same function.
fix(installer): on WSL, warn when the Windows-side WSL is older than …
CristianCrane commentedon Nov 7, 2026
What fires every 30 minutes, on one machine: Claude Code's managed-settings poll
WSL 2.6.1.0, kernel 6.6.87.2-1, Windows 10.0.19045.6466, NixOS-WSL with systemd, WezTerm. Claude Code 2.1.308.
Same signature as above: a tab's
wsl.exewith 5 threads pinned at 100% and 176,832 s of kernel time, and the VM'swslhost.exe --vm-id … --handle …(parentwslservice.exe) with 2 more.The spinning threads were created on 30-minute grids, one per Linux process.
ProcessThread.StartTimeof each pinned thread:wsl.exe: 17:41:26, 21:41:32, 06:11:32, 09:41:32, 14:41:32. The tab'sclaudeprocess started at 17:41:26.wslhost.exe: 09:00:56, 13:00:56. A Claude Code background process (claude bg-spare) started at :30:52, and itsWSL_INTEROPsocket belonged to a tab since closed, so its calls fell back to the VM-level interop server.Caught at the tick. Polling
/procevery 100 ms for new processes, at exactly 20:00:56.66 thebg-spareprocess started two interop launches at the same instant:Claude Code's docs say the Windows policy is "read at startup and checked for changes every 30 minutes", on WSL too. Every Claude Code start makes the same pair. Both are short-lived, which may be why 200 ms sampling saw no new process at the step.
Removing the trigger. In a private mount namespace (
unshare --user --map-root-user --mount), with an empty file bind-mounted over/mnt/c/Windows/System32/reg.exe,claudestarted normally and made no interop launches at all, against two with the real file.So on machines running Claude Code, the 30-minute clock is that poll. Each pair of
reg.exelaunches is a chance to lose the race inProcessInteropMessagesthis issue describes.CristianCrane commentedon Nov 7, 2026
Reported to Claude Code as well: anthropics/claude-code#108203