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..87bc919 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -375,9 +375,50 @@ 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 { + # 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 + Say " could not measure the loopback round-trip -> Direction B over hvsocket" + } +} + # 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..05b2eea 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,72 @@ 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; + // 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\""); + 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 +136,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 +198,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 +369,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() {