Skip to content

Concurrent wsl.exe clients can restore CONIN$ mode out of order and leave VT input enabled #41201

Description

@see-the-world-please

Windows Version

Microsoft Windows [Version 10.0.22000] (Windows 11 Pro x64)

WSL Version

2.7.11.0 (Microsoft Store package version)

Are you using WSL 1 or WSL 2?

  • WSL 2
  • WSL 1

Kernel Version

Not relevant to the observed race; it occurs in the Windows-side wsl.exe console-state handling. The exact kernel version was not collected while another long-running session was active.

Distro Version

The default WSL 2 distribution was used only to run POSIX sh and sleep; no distro-specific behavior is involved.

Other Software

Repro Steps

Open a fresh, disposable PowerShell window. The following script snapshots the original input mode, starts two overlapping wsl.exe clients in the same inherited console, prints the mode timeline, and restores the original mode in finally so that the test window is not left broken.

Add-Type @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

public static class ConsoleInputMode
{
    private const int STD_INPUT_HANDLE = -10;

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern IntPtr GetStdHandle(int nStdHandle);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool GetConsoleMode(IntPtr handle, out uint mode);

    [DllImport("kernel32.dll", SetLastError = true)]
    private static extern bool SetConsoleMode(IntPtr handle, uint mode);

    public static uint Read()
    {
        IntPtr handle = GetStdHandle(STD_INPUT_HANDLE);
        uint mode;
        if (!GetConsoleMode(handle, out mode))
            throw new Win32Exception(Marshal.GetLastWin32Error());
        return mode;
    }

    public static void Write(uint mode)
    {
        IntPtr handle = GetStdHandle(STD_INPUT_HANDLE);
        if (!SetConsoleMode(handle, mode))
            throw new Win32Exception(Marshal.GetLastWin32Error());
    }
}
'@

function Start-SleepingWsl([int] $Seconds) {
    $startInfo = [Diagnostics.ProcessStartInfo]::new()
    $startInfo.FileName = "$env:SystemRoot\System32\wsl.exe"
    $startInfo.Arguments = "-- sh -c `"sleep $Seconds`""
    $startInfo.UseShellExecute = $false
    [Diagnostics.Process]::Start($startInfo)
}

$initial = [ConsoleInputMode]::Read()
$a = $null
$b = $null

try {
    'Initial       : 0x{0:X}' -f $initial

    $a = Start-SleepingWsl 3

    $deadline = (Get-Date).AddSeconds(5)
    do {
        Start-Sleep -Milliseconds 10
        $duringA = [ConsoleInputMode]::Read()
    } while ($duringA -eq $initial -and (Get-Date) -lt $deadline)

    'A active      : 0x{0:X}' -f $duringA
    if ($duringA -eq $initial) {
        throw 'WSL did not change the console input mode before the timeout.'
    }

    $b = Start-SleepingWsl 7
    Start-Sleep -Milliseconds 500

    $a.WaitForExit()
    'After A exits : 0x{0:X} (A={1}, B still running={2})' -f `
        [ConsoleInputMode]::Read(), $a.ExitCode, (-not $b.HasExited)

    $b.WaitForExit()
    $final = [ConsoleInputMode]::Read()
    'After B exits : 0x{0:X} (B={1})' -f $final, $b.ExitCode
}
finally {
    if ($a -and -not $a.HasExited) { $a.WaitForExit() }
    if ($b -and -not $b.HasExited) { $b.WaitForExit() }
    [ConsoleInputMode]::Write($initial)
    'Restored      : 0x{0:X}' -f [ConsoleInputMode]::Read()
}

Representative output from the isolated test:

Initial       : 0x1F7
A active      : 0x3D8
After A exits : 0x1F7 (A=0, B still running=True)
After B exits : 0x3D8 (B=0)
Restored      : 0x1F7

Control tests:

  • A single wsl.exe invocation restores the original mode.
  • 30 strictly serial wsl.exe -- true invocations restored correctly 30/30 times.
  • One WSL process overlapping an ordinary PowerShell process restored correctly in either exit order.
  • PowerShell-to-WSL execution with inherited or redirected stdout/stderr restored correctly in 15/15 tests.
  • Two overlapping WSL clients restore correctly when the client that started first also exits last.
  • The failure is deterministic when client A saves the original mode, client B saves A's temporary mode, A exits first, and B exits last.

Expected Behavior

After all wsl.exe clients attached to a console have exited, the console input mode should be the mode that existed before the first client started. One client's temporary WSL mode should never become the final persistent mode solely because overlapping clients exited in a different order.

Actual Behavior

The final input mode is 0x3D8, even though both WSL processes exit successfully. This leaves ENABLE_VIRTUAL_TERMINAL_INPUT (0x200) enabled and also reflects WSL's other temporary flag changes.

For native Windows TUIs that consume Win32/crossterm key events, arrow and editing keys are then translated into VT sequences and their tails can appear as literal text: arrows become [A/[B/[C/[D, Delete becomes [3~, and Enter/Backspace/control-key handling can fail. The console can remain broken after WSL exits until the original input mode is restored or the console is recreated.

The behavior matches WSL 2.7.11's ConsoleState implementation:

For overlapping clients, the observed sequence is:

A saves original mode, then applies WSL mode
B saves A's WSL mode
A exits and restores original mode
B exits and restores the WSL mode it saved

Possible fix directions include coordinating console-state ownership across overlapping wsl.exe clients, reference-counting per console input buffer, or avoiding restoration of a snapshot known to represent another live client's temporary state.

Diagnostic Logs

No private application/session logs are required for this standalone reproduction. I can collect WSL diagnostics for the isolated test if maintainers need them, but I have intentionally not attached unrelated project data.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions