From 358d96b85b1e48b914c13a65f5ac747c2d92640f Mon Sep 17 00:00:00 2001 From: Zoltan Csizmadia Date: Sat, 5 Sep 2026 20:31:43 -0500 Subject: [PATCH 1/3] transport: pick Direction B's transport by measuring it, not by assuming hvsocket exists because the WSL localhost relay used to cost seconds per round-trip, which made Direction B unusable over TCP. On current WSL it costs a fraction of a millisecond, and where that holds plain TCP is the better choice: no HKLM registration, no `wsl --shutdown` during install, no Hyper-V admin at run time - and therefore no elevated logon task, so the write-capable Windows agent stops running as Administrator. Which one applies cannot be decided from the installed WSL version. A running WSL2 VM keeps the kernel it booted with, so a machine whose WSL updated months ago stays on the old kernel until the VM next restarts, and two machines reporting the same `wsl --version` can behave differently. A version check would be wrong on exactly the machines this affects. So measure the thing that varies. wsldrive probe-transport [--distro X] median round-trip in ms; exit 0 when TCP is fine, 1 when it is not, 2 when it could not be measured wsldrive doctor --probe-transport the same number, in doctor's report The probe uses bash's /dev/tcp for the far end, so it works before anything has been staged into the distro: it binds a loopback listener here, has the distro run `cat <&3 >&3`, and times twenty round-trips from the Windows side. install.ps1 now probes when no transport flag was given and picks accordingly, reporting the number it measured. An explicit -NoHvsocket still wins, and a failed probe falls back to hvsocket rather than guessing that TCP is fine. Verified: probe reports 0.085-0.201 ms here and the installer picks TCP, with the logon task registered "normal" and no HKLM registration or WSL restart in the plan; -NoHvsocket short-circuits the probe; a nonexistent distro degrades to a warning in about ten seconds rather than hanging. Linux 135/135, Windows 133/133; conformance 31/0 Linux, 28/0 Windows. README documents the choice, why it is measured rather than looked up, and how to force either transport. Closes #110 --- README.md | 38 +++++++++--- scripts/install.ps1 | 33 ++++++++++- src/tools/wsldrive_main.cpp | 112 +++++++++++++++++++++++++++++++++++- 3 files changed, 174 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5520a62..3edd39e 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ your distro). The only thing you supply is what to mount where. No elevation for the mount, no registry changes, no WSL restart; the drive is visible to your normal apps and comes back on every reboot. This is the common case and the biggest WSL2 pain. - **Advanced — Direction A *and* B, side by side.** Adds a Windows folder mounted *inside* WSL - (a Linux path, not a drive letter). Direction B uses the Hyper-V socket transport, which needs a - one-time elevated registration and a single `wsl --shutdown` — the installer does both. + (a Linux path, not a drive letter). The installer **measures** the loopback round-trip to your + distro and picks Direction B's transport from that (see [Transports](#transports)); on current WSL + that means plain TCP, with no registry changes and no `wsl --shutdown`. ### 1. Download the installer (easiest — no build) @@ -142,12 +143,35 @@ file's writes and flushes on `fsync`/close for write-heavy work. The wire protocol is a small framed binary protocol (24-byte little-endian header + payload) over a stream socket. Two transports: -- **Hyper-V sockets** (`AF_HYPERV` on Windows, `AF_VSOCK` in WSL) — the fast path. WSL2 routes hvsocket +- **Hyper-V sockets** (`AF_HYPERV` on Windows, `AF_VSOCK` in WSL). WSL2 routes hvsocket host→guest, so the **WSL side listens** (`vsock://any:`) and the **Windows side connects** - (`hv://{}:`). Not IP, so no firewall involvement. This is what makes the - WSL→Windows direction fast; see [Enabling hvsocket](#enabling-the-hyper-v-socket-transport). -- **Loopback TCP** — the fallback (works in NAT and mirrored WSL networking). Fast for Windows→WSL - (Direction A); slow for the WSL→Windows request path, which is why Direction B wants hvsocket. + (`hv://{}:`). Not IP, so no firewall involvement. Needed for Direction B only on + machines where the loopback relay is slow; see [Enabling hvsocket](#enabling-the-hyper-v-socket-transport). +- **Loopback TCP** — works in NAT and mirrored WSL networking, and needs no setup at all. Always + used for Windows→WSL (Direction A). Whether it is fast enough for WSL→Windows (Direction B) + depends on the machine, so wsldrive measures rather than assumes. + +#### Which one you get, and why it is measured + +hvsocket exists because the WSL localhost relay used to cost **seconds** per round-trip, which made +Direction B unusable over TCP. On current WSL it costs a fraction of a millisecond, and where that +holds plain TCP is the better choice: no `HKLM` registration, no `wsl --shutdown` during install, no +Hyper-V admin at run time — and so no elevated logon task, which means the write-capable Windows +agent no longer runs as Administrator. + +This **cannot be decided from your WSL version**. A running WSL2 VM keeps the kernel it booted with, +so a machine whose WSL updated months ago can still be on the old kernel until the VM next restarts — +and two machines reporting the same `wsl --version` can behave differently. So the installer probes: + +```powershell +wsldrive probe-transport --distro Ubuntu # prints the median round-trip in ms + # exit 0 = TCP is fine, 1 = use hvsocket, 2 = could not measure +wsldrive doctor --probe-transport # the same measurement, in doctor's report +``` + +Under ~5 ms the installer picks TCP; above it, hvsocket (and then does the registration and the WSL +restart). Pass `-NoHvsocket` to force TCP, or `-Advanced` with hvsocket left on to force that; an +explicit flag always wins over the probe. ### Semantics (ext4 ↔ Windows) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 44e2c33..e6b7244 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -375,9 +375,40 @@ function Get-TaskName($m) { # =========================================================================== Step 'Configuration' -$useHv = -not $NoHvsocket $distro = if ($Distro) { $Distro } else { Get-DefaultDistro } +# Direction B transport: measure, do not guess. +# +# hvsocket exists because the WSL localhost relay used to cost seconds per +# round-trip. On current WSL it costs a fraction of a millisecond, and where +# that holds, plain TCP is the better choice - it needs no HKLM registration, +# no `wsl --shutdown`, and no elevated logon task, so the write-capable Windows +# agent stops running as Administrator. +# +# This cannot be decided from the installed WSL version. A running WSL2 VM +# keeps the kernel it booted with, so two machines on identical WSL versions +# behave differently depending on whether the VM has restarted since its +# kernel updated. So probe the thing that actually varies. +if ($PSBoundParameters.ContainsKey('NoHvsocket')) { + $useHv = -not $NoHvsocket # explicit wins, always +} else { + # $srcCli is the built wsldrive.exe this install will copy; probe with it. + $probe = $srcCli + if (Test-Path $probe) { + $rtt = & $probe probe-transport --distro $distro 2>$null + $fast = ($LASTEXITCODE -eq 0) + if ($LASTEXITCODE -eq 2 -or -not $rtt) { + $useHv = $true + Warn "Could not measure the loopback round-trip; using hvsocket for Direction B." + } else { + $useHv = -not $fast + Say " loopback round-trip $rtt ms -> Direction B over $(if($useHv){'hvsocket'}else{'TCP'})" + } + } else { + $useHv = $true # cannot probe without the binary; keep the safe default + } +} + # Every mount is one entry here, so several drives / distros coexist: each gets # its own port and its own logon task. $mounts = @() diff --git a/src/tools/wsldrive_main.cpp b/src/tools/wsldrive_main.cpp index 28c1b1b..3671aa1 100644 --- a/src/tools/wsldrive_main.cpp +++ b/src/tools/wsldrive_main.cpp @@ -28,6 +28,8 @@ #include #endif +#include +#include #include #include #include @@ -60,7 +62,69 @@ DWORD run_quiet(std::wstring cmd) { return code; } +// Starts a command without waiting for it. The caller kills it via the returned +// handle; nullptr means it could not be started. +HANDLE spawn_quiet(std::wstring cmd) { + cmd.push_back(L'\0'); + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (!::CreateProcessW(nullptr, cmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) + return nullptr; + ::CloseHandle(pi.hThread); + return pi.hProcess; +} std::wstring widen(std::string_view s) { return wsld::platform::win::to_wide(s); } + +// Median round-trip time, in milliseconds, from inside the distro back to a +// listener on Windows over loopback. std::nullopt if it could not be measured. +// +// This one number decides whether Direction B needs the Hyper-V socket +// transport, and it cannot be inferred from the installed WSL version: a +// running WSL2 VM keeps the kernel it booted with, so a machine can carry the +// improvement for months without it taking effect. Two machines on identical +// WSL versions can differ, which is why this measures rather than checks. +// +// Uses bash's /dev/tcp rather than our own binaries, so it works before +// anything has been staged into the distro. The distro end is a plain echo +// (`cat <&3 >&3`); all the timing happens here. +std::optional probe_loopback_rtt(const std::string& distro) { + auto listener = wsld::net::Listener::bind(*wsld::net::Endpoint::parse("tcp://127.0.0.1:0")); + if (!listener) return std::nullopt; + const std::uint16_t port = listener->local().port; + const std::wstring d = distro.empty() ? L"" : L" -d " + widen(distro); + HANDLE echo = spawn_quiet(L"wsl.exe" + d + L" -e bash -lc \"exec 3<>/dev/tcp/127.0.0.1/" + + std::to_wstring(port) + L"; cat <&3 >&3\""); + std::optional result; + if (auto peer = listener->accept(std::chrono::seconds(10))) { + std::vector rtt; + std::byte out{std::byte{42}}; + std::byte in{}; + for (int i = 0; i < 20; ++i) { + const auto t0 = std::chrono::steady_clock::now(); + if (!peer->send_all(std::span(&out, 1))) break; + if (!peer->recv_exact(std::span(&in, 1))) break; + rtt.push_back(std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); + } + if (rtt.size() >= 5) { + std::sort(rtt.begin(), rtt.end()); + result = rtt[rtt.size() / 2]; + } + peer->close(); + } + if (echo != nullptr) { + ::TerminateProcess(echo, 0); + ::CloseHandle(echo); + } + listener->close(); + return result; +} + +// Below this, plain TCP is as good as the Hyper-V socket transport for +// Direction B. The slow path was seconds per round-trip, not milliseconds, so +// the threshold does not have to be delicate. +constexpr double kFastLoopbackMs = 5.0; + #endif void usage() { @@ -69,7 +133,8 @@ void usage() { "usage:\n" " wsldrive fetch (--connect | --listen ) [--watch] [--read ] [--lookups N]\n" #ifdef _WIN32 - " wsldrive doctor [--distro X] [--hvsocket] [--port N] [--pause]\n" + " wsldrive doctor [--distro X] [--hvsocket] [--port N] [--probe-transport] [--pause]\n" + " wsldrive probe-transport [--distro X] (loopback round-trip, ms; for scripts)\n" " (check the WinFsp + WSL environment)\n" #endif #ifdef WSLDRIVE_HAVE_MOUNT @@ -130,17 +195,41 @@ int main(int argc, char** argv) { const std::string_view command = argc >= 2 ? std::string_view(argv[1]) : std::string_view{}; #ifdef _WIN32 + // Machine-readable form of doctor's transport probe, for the installer: prints + // the median round-trip in milliseconds and nothing else, exits 0 when it is + // fast enough for plain TCP and 1 when it is not (2 if it could not measure). + // The installer picks the Direction B transport from this rather than from a + // WSL version number, which would be wrong on any VM that has not restarted + // since its kernel updated. + if (command == "probe-transport") { + std::string distro; + for (int i = 2; i < argc; ++i) { + const std::string_view a = argv[i]; + if (a == "--distro" && i + 1 < argc) distro = argv[++i]; + else { + std::fprintf(stderr, "probe-transport: unknown option %.*s\n", static_cast(a.size()), a.data()); + return 2; + } + } + const auto rtt = probe_loopback_rtt(distro); + if (!rtt) return 2; + std::printf("%.3f\n", *rtt); + return *rtt < kFastLoopbackMs ? 0 : 1; + } + if (command == "doctor") { // doctor [--distro X] [--hvsocket] [--port N] [--pause] std::string want_distro; int want_port = 0; bool check_hv = false; + bool probe_transport = false; bool pause_at_end = false; for (int i = 2; i < argc; ++i) { const std::string_view a = argv[i]; if (a == "--distro" && i + 1 < argc) want_distro = argv[++i]; else if (a == "--port" && i + 1 < argc) want_port = std::atoi(argv[++i]); else if (a == "--hvsocket") check_hv = true; + else if (a == "--probe-transport") probe_transport = true; else if (a == "--pause") pause_at_end = true; // the installer runs doctor in a console that closes else { std::fprintf(stderr, "doctor: unknown option %.*s\n", static_cast(a.size()), a.data()); @@ -277,6 +366,27 @@ int main(int argc, char** argv) { } } + // How long is a round-trip from inside the distro back to Windows over + // loopback? That one number decides whether Direction B needs the Hyper-V + // socket transport, and it cannot be inferred from the installed WSL + // version: a running WSL2 VM keeps the kernel it booted with, so a machine + // can carry the improvement for months without it taking effect. + // + // Measured with bash's /dev/tcp rather than our own binaries, so it works + // before anything has been staged into the distro. The distro end is a + // plain echo (`cat <&3 >&3`); all the timing happens here. + if (probe_transport) { + if (const auto rtt = probe_loopback_rtt(want_distro)) { + std::printf("[ok] loopback round-trip from the distro: %.3f ms\n", *rtt); + if (*rtt < kFastLoopbackMs) + std::printf(" fast enough for Direction B over plain TCP; hvsocket is not required here\n"); + else + std::printf(" slow: Direction B needs the Hyper-V socket transport on this machine\n"); + } else { + std::printf("[warn] transport probe: could not measure (no bash /dev/tcp in the distro?)\n"); + } + } + struct PauseAtExit { bool on; ~PauseAtExit() { From 58c206acf768fdec5d740d273d1ba5e999ca2e95 Mon Sep 17 00:00:00 2001 From: Zoltan Csizmadia Date: Sat, 5 Sep 2026 20:35:33 -0500 Subject: [PATCH 2/3] fix: do not narrow the probe port to uint16_t Endpoint::port is a uint32_t and was being assigned to a uint16_t purely to format it. MSVC 14.51 treats that conversion as an error under /W4 /WX, which failed all three Windows CI jobs; the local toolchain here is 14.42 and does not flag it, so the local build was a weaker gate than CI. There was no reason to convert at all. --- src/tools/wsldrive_main.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tools/wsldrive_main.cpp b/src/tools/wsldrive_main.cpp index 3671aa1..05b2eea 100644 --- a/src/tools/wsldrive_main.cpp +++ b/src/tools/wsldrive_main.cpp @@ -91,7 +91,10 @@ std::wstring widen(std::string_view s) { return wsld::platform::win::to_wide(s); std::optional probe_loopback_rtt(const std::string& distro) { auto listener = wsld::net::Listener::bind(*wsld::net::Endpoint::parse("tcp://127.0.0.1:0")); if (!listener) return std::nullopt; - const std::uint16_t port = listener->local().port; + // Endpoint::port is a uint32_t; keep it that way rather than narrowing to + // uint16_t just to format it (MSVC 14.51 treats that conversion as an error + // under /W4 /WX, and there is no reason to convert at all). + const std::uint32_t port = listener->local().port; const std::wstring d = distro.empty() ? L"" : L" -d " + widen(distro); HANDLE echo = spawn_quiet(L"wsl.exe" + d + L" -e bash -lc \"exec 3<>/dev/tcp/127.0.0.1/" + std::to_wstring(port) + L"; cat <&3 >&3\""); From 836f11150fca2b2d885189026474f171d864e595 Mon Sep 17 00:00:00 2001 From: Zoltan Csizmadia Date: Sat, 5 Sep 2026 20:39:49 -0500 Subject: [PATCH 3/3] installer: make the transport probe best-effort The probe ran the wsldrive.exe this install would copy, which is fine on a real machine and fatal in CI: the installer dry-run job stands placeholder text files in for the binaries, so the exec failed, and with ErrorActionPreference = Stop that aborted the whole script. Nothing about choosing a transport should be able to stop an install. A missing binary, a placeholder standing in for one, or a distro that will not start now all fall back to hvsocket rather than assuming TCP is fine, and the output says which happened. Reproduced the CI conditions locally rather than guessing again: with placeholder binaries the dry run reports "could not measure the loopback round-trip -> Direction B over hvsocket" and exits 0; with real binaries it still measures 0.171 ms and picks TCP. --- scripts/install.ps1 | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e6b7244..87bc919 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -392,20 +392,30 @@ $distro = if ($Distro) { $Distro } else { Get-DefaultDistro } if ($PSBoundParameters.ContainsKey('NoHvsocket')) { $useHv = -not $NoHvsocket # explicit wins, always } else { - # $srcCli is the built wsldrive.exe this install will copy; probe with it. - $probe = $srcCli - if (Test-Path $probe) { - $rtt = & $probe probe-transport --distro $distro 2>$null - $fast = ($LASTEXITCODE -eq 0) - if ($LASTEXITCODE -eq 2 -or -not $rtt) { - $useHv = $true - Warn "Could not measure the loopback round-trip; using hvsocket for Direction B." - } else { - $useHv = -not $fast - Say " loopback round-trip $rtt ms -> Direction B over $(if($useHv){'hvsocket'}else{'TCP'})" + # Probe with the wsldrive.exe this install will copy. Everything here is + # best-effort: a missing binary, a placeholder standing in for one (as in CI), + # a distro that will not start - none of that should stop an install, so any + # failure falls back to hvsocket rather than assuming TCP is fine. + $rtt = $null + $probeOk = $false + if ($srcCli -and (Test-Path $srcCli)) { + try { + $out = & $srcCli probe-transport --distro $distro 2>&1 + if ($LASTEXITCODE -le 1) { + $val = 0.0 + $last = ($out | Select-Object -Last 1) + if ([double]::TryParse([string]$last, [ref]$val)) { $rtt = $val; $probeOk = $true } + } + } catch { + $probeOk = $false # not runnable here; fall through } + } + if ($probeOk) { + $useHv = ($rtt -ge 5.0) + Say " loopback round-trip $rtt ms -> Direction B over $(if($useHv){'hvsocket'}else{'TCP'})" } else { - $useHv = $true # cannot probe without the binary; keep the safe default + $useHv = $true + Say " could not measure the loopback round-trip -> Direction B over hvsocket" } }