From 71ea7214cef05aa384382ceebee2e9ece0a99ca8 Mon Sep 17 00:00:00 2001 From: "Gustavo Bertoi (WSL Windows 29/06/2026)" Date: Thu, 9 Jul 2026 22:14:37 -0300 Subject: [PATCH 1/2] fix(expose): skip Windows/Hyper-V excluded port ranges on WSL2 On WSL2 + Docker Desktop, Windows and Hyper-V dynamically reserve TCP port ranges (netsh excludedportrange), and those ranges move on every Windows reboot. The host-port allocator only bind-tested inside the Linux distro, which cannot see the Windows-side reservation, so it would hand out a port (e.g. minio's 59000) that Docker Desktop's Windows-side forward then rejects with: ports are not available: exposing port TCP 127.0.0.1:59000 -> 127.0.0.1:0: /forwards/expose returned unexpected status: 500 FreeHostPort now queries netsh.exe on WSL2, treats excluded ranges as unavailable during allocation, and releases a persisted port that has fallen into a newly-excluded range so it re-allocates a usable one. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/state/ledger.go | 13 +++ internal/workspace/ports.go | 23 ++++- internal/workspace/ports_excluded.go | 120 ++++++++++++++++++++++ internal/workspace/ports_excluded_test.go | 82 +++++++++++++++ 4 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 internal/workspace/ports_excluded.go create mode 100644 internal/workspace/ports_excluded_test.go diff --git a/internal/state/ledger.go b/internal/state/ledger.go index 4358559..98bcb06 100644 --- a/internal/state/ledger.go +++ b/internal/state/ledger.go @@ -283,6 +283,19 @@ func (db *DB) ReleasePortsFor(owner string) error { return nil } +// ReleasePort removes the single (owner, purpose) allocation so the next +// AllocatePort re-picks a port. Used when a persisted port has become +// unpublishable (e.g. it now falls inside a Windows/Hyper-V excluded range). +// Hold the lock. +func (db *DB) ReleasePort(owner, purpose string) error { + _, err := db.Exec(`DELETE FROM port_alloc WHERE ctx=? AND owner=? AND purpose=?`, + db.Ctx, owner, purpose) + if err != nil { + return fmt.Errorf("release port %s/%s: %w", owner, purpose, err) + } + return nil +} + // --- provisioning ownership ledger ---------------------------------------- // RecordProvisioned ties a provisioned db/role/bucket/redis_index to a project diff --git a/internal/workspace/ports.go b/internal/workspace/ports.go index b794ce1..f29132a 100644 --- a/internal/workspace/ports.go +++ b/internal/workspace/ports.go @@ -20,9 +20,12 @@ const ( // FreeHostPort allocates a stable host port for (owner, purpose), persisting it // inside the lock. A port is considered free only if it is ALL of: not already // persisted in the ledger, bindable on 127.0.0.1 (advisory), and not published -// by a live tool-managed container. The last check is essential on Docker Desktop -// (macOS/WSL2), where a host bind-test does not reflect the VM's port proxy, so -// the bind-test alone would hand out a port Docker already holds (spec 03/08). +// by a live tool-managed container. The published check is essential on Docker +// Desktop (macOS/WSL2), where a host bind-test does not reflect the VM's port +// proxy, so the bind-test alone would hand out a port Docker already holds (spec +// 03/08). On WSL2 it additionally skips Windows/Hyper-V excluded port ranges (see +// ports_excluded.go) that a Linux-side bind-test cannot see but Docker Desktop's +// Windows-side forward rejects with a 500. func (m *Manager) FreeHostPort(ctx context.Context, owner, purpose string, base int) (int, error) { published, err := m.publishedPorts(ctx) if err != nil { @@ -30,9 +33,21 @@ func (m *Manager) FreeHostPort(ctx context.Context, owner, purpose string, base } var port int err = lock.WithLock(ctx, m.LockPath, func() error { + // A port persisted on a previous run may now sit inside a Windows/Hyper-V + // excluded range (those move across reboots on WSL2). AllocatePort returns + // a persisted port verbatim without re-checking, so an excluded one would + // be handed back forever and every publish would fail — release it first so + // a usable port is picked. + if p, ok, e := m.DB.PortFor(owner, purpose); e != nil { + return e + } else if ok && portExcluded(p) { + if e := m.DB.ReleasePort(owner, purpose); e != nil { + return e + } + } var e error port, e = m.DB.AllocatePort(owner, purpose, base, base+portRangeSpan, func(p int) bool { - return !published[p] && bindable(p) + return !published[p] && !portExcluded(p) && bindable(p) }) return e }) diff --git a/internal/workspace/ports_excluded.go b/internal/workspace/ports_excluded.go new file mode 100644 index 0000000..649cade --- /dev/null +++ b/internal/workspace/ports_excluded.go @@ -0,0 +1,120 @@ +package workspace + +import ( + "bufio" + "context" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/open-source-cloud/devstack/internal/xdg" +) + +// netshTimeout bounds the netsh.exe call so a wedged Windows side never hangs +// the CLI; on timeout we fall back to "no exclusions" (the bind-test still runs). +const netshTimeout = 3 * time.Second + +// portRange is an inclusive [start,end] host-port range that cannot be published. +type portRange struct{ start, end int } + +// netshRunner returns the raw `netsh int ipv4 show excludedportrange` output. +// Overridable in tests; nil in production means "shell out to netsh.exe". +var netshRunner func() string + +// wsl2Detect gates the exclusion query to WSL2. A package var so tests can force +// the WSL2 path deterministically on any platform. +var wsl2Detect = xdg.IsWSL2 + +var ( + excludedMu sync.Mutex + excludedComputed bool + excludedCache []portRange +) + +// excludedPortRanges returns the host-port ranges that cannot be bound as a +// published Docker port on this host. On WSL2 with Docker Desktop, Windows and +// Hyper-V DYNAMICALLY reserve TCP port ranges (`netsh int ipv4 show +// excludedportrange protocol=tcp`); the ranges change on every Windows reboot. +// A bind-test inside the Linux distro does NOT see them, so Docker Desktop's +// Windows-side port forward fails with: +// +// ports are not available: exposing port TCP 127.0.0.1:X -> 127.0.0.1:0: +// /forwards/expose returned unexpected status: 500 +// +// Treating these ranges as unavailable during allocation is what keeps `expose` +// (and any other host-published port) working on WSL2. Non-WSL2 hosts have no +// such exclusions and return nil. Result is cached for this process' lifetime +// (the CLI is short-lived; ranges are stable within a boot). +func excludedPortRanges() []portRange { + excludedMu.Lock() + defer excludedMu.Unlock() + if !excludedComputed { + excludedComputed = true + if wsl2Detect() { + run := netshRunner + if run == nil { + run = runNetshExcluded + } + excludedCache = parseExcludedPortRanges(run()) + } + } + return excludedCache +} + +// resetExcludedCache clears the memoized ranges so a later call recomputes. +// Used by tests that inject a fake netsh output; a no-op cost in production. +func resetExcludedCache() { + excludedMu.Lock() + excludedComputed = false + excludedCache = nil + excludedMu.Unlock() +} + +// runNetshExcluded shells out to the Windows netsh.exe (reachable from WSL2) for +// the TCP excluded-port-range table. A failure (netsh missing, non-Desktop WSL2) +// yields no exclusions rather than an error — the bind-test remains the backstop. +func runNetshExcluded() string { + ctx, cancel := context.WithTimeout(context.Background(), netshTimeout) + defer cancel() + out, err := exec.CommandContext(ctx, "netsh.exe", "int", "ipv4", + "show", "excludedportrange", "protocol=tcp").Output() + if err != nil { + return "" + } + return string(out) +} + +// parseExcludedPortRanges extracts inclusive [start,end] pairs from netsh's +// excluded-port-range table. Each data row is two integers (start, end) with an +// optional trailing "*" note; the title, header, and separator lines have no +// leading integer pair. Matching on the "two integers begin the line" shape (not +// on column headings) keeps it locale-independent — netsh localizes its headers. +func parseExcludedPortRanges(text string) []portRange { + var ranges []portRange + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 2 { + continue + } + start, err1 := strconv.Atoi(fields[0]) + end, err2 := strconv.Atoi(fields[1]) + if err1 != nil || err2 != nil || start <= 0 || end < start { + continue + } + ranges = append(ranges, portRange{start, end}) + } + return ranges +} + +// portExcluded reports whether p falls inside any host-reserved excluded range. +func portExcluded(p int) bool { + for _, r := range excludedPortRanges() { + if p >= r.start && p <= r.end { + return true + } + } + return false +} diff --git a/internal/workspace/ports_excluded_test.go b/internal/workspace/ports_excluded_test.go new file mode 100644 index 0000000..cb5e9f9 --- /dev/null +++ b/internal/workspace/ports_excluded_test.go @@ -0,0 +1,82 @@ +package workspace + +import ( + "context" + "testing" +) + +// netshSample mirrors real `netsh int ipv4 show excludedportrange` output, +// including the Portuguese localized header (the parser must be locale-agnostic) +// and the trailing "*" note on administered exclusions. +const netshSample = ` +Protocolo tcp Intervalos de Exclusão de Porta + +Porta Inicial Porta Final +---------- -------- + 50000 50059 * + 54235 54235 + 58956 59055 + 59056 59155 + +* - Exclusões de porta administradas. +` + +func TestParseExcludedPortRanges(t *testing.T) { + got := parseExcludedPortRanges(netshSample) + want := []portRange{{50000, 50059}, {54235, 54235}, {58956, 59055}, {59056, 59155}} + if len(got) != len(want) { + t.Fatalf("parsed %d ranges, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("range %d = %+v, want %+v", i, got[i], want[i]) + } + } +} + +func TestParseExcludedPortRangesIgnoresJunk(t *testing.T) { + if r := parseExcludedPortRanges(""); r != nil { + t.Errorf("empty input yielded %+v", r) + } + // Header-only (no data rows) → no ranges. A reversed/invalid pair is dropped. + if r := parseExcludedPortRanges("Start End\n---- ----\n900 100\n"); len(r) != 0 { + t.Errorf("invalid rows yielded %+v", r) + } +} + +// withExcludedRanges forces the WSL2 exclusion path with a fake netsh output for +// one test (deterministic on any platform) and restores + clears the cache after. +func withExcludedRanges(t *testing.T, output string) { + t.Helper() + prevRunner, prevDetect := netshRunner, wsl2Detect + netshRunner = func() string { return output } + wsl2Detect = func() bool { return true } + resetExcludedCache() + t.Cleanup(func() { + netshRunner, wsl2Detect = prevRunner, prevDetect + resetExcludedCache() + }) +} + +func TestFreeHostPortReallocatesExcludedPersistedPort(t *testing.T) { + withExcludedRanges(t, netshSample) + m := newManager(t, nil) + ctx := context.Background() + + // Pre-seed the ledger with a port that lands inside an excluded range, + // simulating an allocation made before Windows reserved that range. + if p, err := m.DB.AllocatePort("minio", "minio-expose", 59000, 59000, nil); err != nil || p != 59000 { + t.Fatalf("seed port: got %d, err %v", p, err) + } + port, err := m.FreeHostPort(ctx, "minio", "minio-expose", 59000) + if err != nil { + t.Fatal(err) + } + if portExcluded(port) { + t.Fatalf("re-allocated into an excluded range: %d", port) + } + // Stable afterward. + if again, _ := m.FreeHostPort(ctx, "minio", "minio-expose", 59000); again != port { + t.Errorf("port not stable after reallocation: %d vs %d", again, port) + } +} From eecb469a2dde5ceeaf93ae5a26ddc1c62072a4ab Mon Sep 17 00:00:00 2001 From: "Gustavo Bertoi (WSL Windows 29/06/2026)" Date: Thu, 9 Jul 2026 22:16:48 -0300 Subject: [PATCH 2/2] test(expose): use English netsh sample in port-exclusion test Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/workspace/ports_excluded_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/workspace/ports_excluded_test.go b/internal/workspace/ports_excluded_test.go index cb5e9f9..6681b17 100644 --- a/internal/workspace/ports_excluded_test.go +++ b/internal/workspace/ports_excluded_test.go @@ -6,19 +6,19 @@ import ( ) // netshSample mirrors real `netsh int ipv4 show excludedportrange` output, -// including the Portuguese localized header (the parser must be locale-agnostic) -// and the trailing "*" note on administered exclusions. +// including the header/separator lines (which the parser must skip regardless of +// locale) and the trailing "*" note on administered exclusions. const netshSample = ` -Protocolo tcp Intervalos de Exclusão de Porta +Protocol tcp Port Exclusion Ranges -Porta Inicial Porta Final +Start Port End Port ---------- -------- 50000 50059 * 54235 54235 58956 59055 59056 59155 -* - Exclusões de porta administradas. +* - Administered port exclusions. ` func TestParseExcludedPortRanges(t *testing.T) {