Skip to content

ProcessInteropMessages spins at 100% CPU forever when an interop control socket reaches EOF #41173

Open

Description

@racterub

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:

  1. Different file. wsl.exe relay loops in src/windows/common/relay.cpp miss bytesRead == 0 EOF check on synchronous-success path; cause wsl.exe to spin at 100% on AFD sockets #40651 points at src/windows/common/relay.cpp. A
    maintainer replied
    that ScopedMultiRelay "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
    ProcessInteropMessages in src/windows/common/interop.cpp, which is a
    different function with a different exit structure.
  2. 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.
  3. The requested dumps exist. The same comment asked for "several dumps
    seconds apart with procdump -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:

  1. Open a WSL2 session in a terminal and leave it open for days
  2. Use it normally — in particular, run Windows binaries from inside Linux
    (explorer.exe ., cmd.exe /c …, git.exe, code, clip.exe, anything
    routed through the WSLInterop binfmt handler)
  3. Watch wsl.exe thread 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:

  1. The launched Windows process exits → WaitForMultipleObjects wakes on the
    process handle (interop.cpp:467) → loop breaks
  2. Windows sends LX_INIT_PROCESS_EXIT_STATUS (interop.cpp:338)
  3. Linux receives it (binfmt.cpp:320), returns at :398, and its
    wil::unique_fd destructors 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 ReadFile after 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:559 passes SignalPipe.first.get(),
    a real named pipe (OpenAnonymousPipe → NtCreateNamedPipeFile). A closed
    pipe yields ERROR_BROKEN_PIPE, which is handled. No spin.

  • WSL2 / VM path — interop.cpp:335 passes 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!NtReadFile begins mov r10,rcx and r10 survives the syscall,
    giving the HANDLE directly (0x428, 0x748, 0x710)
  • rdx holds the Event parameter (0x66c); r9 holds ApcContext, which
    KERNELBASE!ReadFile sets to the OVERLAPPED pointer (0xb5c8fff858)
  • reading 32 bytes at that address returns an OVERLAPPED whose hEvent is
    0x66c, matching rdx — 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

  1. github-actions commented on Jul 25, 2026

    @github-actions
    Contributor

    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:

    Invoke-WebRequest -UseBasicParsing "https://raw.githubusercontent.com/microsoft/WSL/master/diagnostics/collect-wsl-logs.ps1" -OutFile collect-wsl-logs.ps1
    Set-ExecutionPolicy Bypass -Scope Process -Force
    .\collect-wsl-logs.ps1
    

    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 networking instead, following the instructions in Collect WSL logs for networking issues

    Once 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'.

  2. racterub commented on Jul 25, 2026

    @racterub
    Author

    /emailed-logs

    Sent wsl41173-dumps.7z (18.4 MB) to wsl-gh-logs@microsoft.com with 41173 in 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:

    dump captured (UTC+8)
    wsl4840-1.dmp 2026-07-25 15:25:58
    wsl4840-2.dmp 2026-07-25 15:26:20
    wsl4840-3.dmp 2026-07-25 15:26:42

    This is the "several dumps seconds apart" that was requested on #40651 and never supplied.

    The frame of interest is in dump 3. The OVERLAPPED belonging to a spinning Interop thread's ReadFile reads Internal = STATUS_SUCCESS, InternalHigh = 0, where dumps 1 and 2 show STATUS_PENDING / 0 for the same thread. That is EOF on the AF_HYPERV socket surfacing as synchronous success with zero bytes — the one completion shape the loop body in ProcessInteropMessages doesn't act on, so it re-issues the read immediately and forever.

    wsl.exe ships without public symbols, so for anyone reproducing the read from the dumps: NtReadFile preserves the handle in r10 across the syscall, and r9 holds ApcContext, which KERNELBASE!ReadFile sets to the OVERLAPPED pointer. Recovering it from the Memory64ListStream yields an hEvent matching rdx, confirming the structure. With private PDBs this should be considerably more direct.

    No collect-wsl-logs.ps1 ETW 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 hvsocket and wait for the next occurrence; it recurs roughly once per 20 hours of normal interop use on this machine.

  3. github-actions commented on Jul 25, 2026

    @github-actions
    Contributor
    Diagnostic information
    Found '/emailed-logs', adding tag 'emailed-logs'
    
  4. dario-spagnolo commented on Aug 1, 2026

    @dario-spagnolo

    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.cpp diagnosis in this report over the relay.cpp attribution in #40651.

    Everything below is measured unless explicitly flagged as inference.

    Environment

    WSL version 2.7.10.0
    Kernel 6.18.33.2-2
    WSLg 1.0.73.2
    Windows 10.0.26100.7462
    Host 16 logical processors
    Distro Debian, WSL 2, systemd enabled

    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:

    1. wslhost.exe is affected, not just wsl.exe. A wslhost.exe --distro-id {...} --vm-id {...} process accumulated 4 threads pinned at 100% and 26.5 hours of CPU over a 24-hour VM uptime.
    2. Sessions reached over SSH are completely immune. Connecting to the distro's sshd over localhost:22 instead of wsl.exe creates no session relay and, crucially, no interop socket. Zero accumulation, verified over three consecutive trigger cycles.
    3. Non-interactive 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.
    4. With no pre-existing session, the trigger has no effect at all — not one thread created anywhere. If the defect needed only a closing socket, something should still have leaked; if it needs an established interop control socket to reach EOF, nothing should. Nothing did.

    Taken together these say: the leak requires a live interop channel. That is exactly what ProcessInteropMessages sits 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, no WaitReason. Closed form:

    threads = 4 + 6n     spinning threads = 2n     CPU = 200n %
    

    Measured on one interactive session relay (wsl.exe, command line "C:\WINDOWS\system32\wsl.exe", no arguments), from a freshly booted VM:

    n time threads handles CPU (% of one core)
    0 09:39–10:10 4 205 0.0 – 0.2
    1 10:10:53 10 239 199.6
    2 10:40:47 16 269 399.0
    3 11:10:43 22 307 595.6
    4 12:10:41 28 341 ~795
    5 12:40:41 34 371 ~990
    6 13:10:42 40 405 1144 – 1186
    7 14:10:29 46 439 (saturated, see note)
    8 14:40:42 52 473 (saturated)
    9 15:10:55 58 507 (saturated)
    10 15:40:41 64 541 (saturated)

    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:

    29.90  29.93  59.97*  30.00  29.70  60.03*  30.28  30.20  29.77   (minutes)
                     * = one cycle produced no step
    

    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.exe keeps accumulating after its session dies

    When the session relay was killed, the wslhost.exe it had spawned survived as an orphan — its ParentProcessId pointing at a dead PID — and kept taking hits on the same 30-minute schedule for three more hours:

    time threads handles CPU (% of one core)
    17:05 (before) 1 189 0
    17:10:27 7 223 199.1
    17:40:55 13 257 356.8, settling to 397
    18:10:52 19 not recorded 534, settling to 594
    18:40:52 25 not recorded 730, settling to 790
    19:10:53 31 not recorded 924, settling to 982

    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.exe processes, at 0.00% CPU:

    19:21  64 threads total   (immediately after the kill)
    19:56  64 threads total   (cycle at 19:40 — no change)
    20:27  64 threads total   (cycle at 20:10 — no change)
    20:58  66 threads total   (cycle at 20:40 — +2 on wslservice.exe at 0.00% CPU;
                               that count moved 15↔18 all day without ever spinning,
                               and it is not the +6/+34/+200% signature)
    

    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:

    baseline (idle)          190 – 250 ms
    at ~8 cores of spin      718 – 924 ms
    at ~12 cores of spin     1325 – 1691 ms
    after cleanup            160 – 190 ms
    

    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 — vmmemWSL at 0% CPU, guest /proc/pressure/cpu at avg10=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 to sshd creates 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 in PATH, and no WSLg environment. DISPLAY=:0 restores 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 by wsl --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 --shutdown clears the VM-level wslhost.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 /proc every 200 ms, which is the only one fine enough to catch sub-second processes. In all three, at the moment of the step:

    • no process created in the guest — for the 15:10:55 step this holds down to sub-second lifetimes; for the other two I can only exclude processes living longer than ~2 s;
    • no new interop launcher (/init /mnt/c/...) — the set was byte-identical across every window;
    • no process created on the Windows side beyond my own instrumentation (2 s sampling).

    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:

    1. Establish the repro. Open one interactive wsl.exe session 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.
    2. Measure the right thing. Sample Threads.Count on the session's wsl.exe (and on any wslhost.exe --distro-id). Each hit is exactly +6 threads, +34 handles, and +2 threads pinned at ~100% with ThreadState = Running and no WaitReason. Do not use CPU percentage as the primary metric — past ~6 spinning threads it saturates and understates.
    3. Verdict. Fixed means the thread count stays flat across several cycles. Unfixed means +6 per hit, monotonically, never released.
    4. Negative control, already characterised. With no interactive session present (reach the distro over SSH instead), the same cycles produce zero new threads — verified over three consecutive cycles. So a flat thread count only counts as evidence of a fix if an interactive session is actually attached.

    The whole thing is an hour or two of wall-clock with no special tooling — Get-Process and a Start-Sleep loop 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.

  5. JackNorris commented on Aug 2, 2026

    @JackNorris

    I hit this back on 2.7.11, before finding the issue. A wsl.exe serving a five
    day 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.

    build leaked spinning threads cores
    official 2.9.4.0 32 31.3
    self-built, unpatched 32 27.3
    self-built + #41205 0 0

    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 SIGWINCH churn. That's why a plain wsl.exe -e <cmd>
    loop never triggers this.

    x64 Release only; I didn't run the test suite.

  6. a1243924018 commented on Aug 13, 2026

    @a1243924018

    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 ProcessInteropMessages spin on wsl.exe/wslhost.exe interop 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 missing BytesRead == 0 (EOF) branch on the synchronous ReadFile success 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.

  7. ccl100 commented on Nov 2, 2026

    @ccl100

    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:

    • One wslhost.exe with the command line --vm-id {…} --handle 3184 uses ~1.9 cores permanently. Its parent is wslservice.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.
    • Exactly 2 of its 7 threads are busy, at ~0.97 cores each and ~648 CPU-hours combined. The other 5 are idle. That fits two leaked Interop threads spinning in ProcessInteropMessages. Because the synchronous path never looks at the process handle, they keep spinning long after the launched Windows process has exited.
    • The 2.6.3 tag has the same gap: in ProcessInteropMessages, a synchronous ReadFile success with BytesRead == 0 falls 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

    1. Process type. This isn't a session relay (wsl.exe) or a session-takeover wslhost.exe --distro-id …. It's the distro's own interop server, which the service starts in WslCoreInstance.cpp ("Launch the interop server with the user's token") via LaunchInteropServer(nullptr, socket, nullptr, nullptr, &m_runtimeId, …). It handles requests through VmModeWorkerThread → CreateProcessVmMode, which starts one Interop thread per request → ProcessInteropMessages.

    2. Why the leaks pile up here. If a process's WSL_INTEROP socket 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 had WSL_INTEROP=/run/WSL/<pid>_interop set, but that socket was gone, presumably because the wsl.exe session 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):

      systemd-run --user -- /mnt/c/Windows/System32/cmd.exe /c "ping -n 6 127.0.0.1"
      

      On the Windows side, the parent of the spawned cmd.exe is the --vm-id … --handle … wslhost.

    3. Killing it isn't a free workaround. This wslhost serves every session-less process of the distro. As far as I can tell, WslCoreInstance starts 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 break powershell.exe/cmd.exe/clip.exe for 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.

  8. CristianCrane commented on Nov 7, 2026

    @CristianCrane

    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.exe with 5 threads pinned at 100% and 176,832 s of kernel time, and the VM's wslhost.exe --vm-id … --handle … (parent wslservice.exe) with 2 more.

    The spinning threads were created on 30-minute grids, one per Linux process. ProcessThread.StartTime of each pinned thread:

    • the tab's wsl.exe: 17:41:26, 21:41:32, 06:11:32, 09:41:32, 14:41:32. The tab's claude process started at 17:41:26.
    • the VM's wslhost.exe: 09:00:56, 13:00:56. A Claude Code background process (claude bg-spare) started at :30:52, and its WSL_INTEROP socket belonged to a tab since closed, so its calls fell back to the VM-level interop server.

    Caught at the tick. Polling /proc every 100 ms for new processes, at exactly 20:00:56.66 the bg-spare process started two interop launches at the same instant:

    /init /mnt/c/Windows/System32/reg.exe query HKLM\SOFTWARE\Policies\ClaudeCode /v Settings
    /init /mnt/c/Windows/System32/reg.exe query HKCU\SOFTWARE\Policies\ClaudeCode /v Settings
    

    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, claude started 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.exe launches is a chance to lose the race in ProcessInteropMessages this issue describes.

  9. CristianCrane commented on Nov 7, 2026

    @CristianCrane

    Reported to Claude Code as well: anthropics/claude-code#108203

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions