Skip to content

Commit 71ea721

Browse files
gustavobertoiclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 3b95676 commit 71ea721

4 files changed

Lines changed: 234 additions & 4 deletions

File tree

internal/state/ledger.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,19 @@ func (db *DB) ReleasePortsFor(owner string) error {
283283
return nil
284284
}
285285

286+
// ReleasePort removes the single (owner, purpose) allocation so the next
287+
// AllocatePort re-picks a port. Used when a persisted port has become
288+
// unpublishable (e.g. it now falls inside a Windows/Hyper-V excluded range).
289+
// Hold the lock.
290+
func (db *DB) ReleasePort(owner, purpose string) error {
291+
_, err := db.Exec(`DELETE FROM port_alloc WHERE ctx=? AND owner=? AND purpose=?`,
292+
db.Ctx, owner, purpose)
293+
if err != nil {
294+
return fmt.Errorf("release port %s/%s: %w", owner, purpose, err)
295+
}
296+
return nil
297+
}
298+
286299
// --- provisioning ownership ledger ----------------------------------------
287300

288301
// RecordProvisioned ties a provisioned db/role/bucket/redis_index to a project

internal/workspace/ports.go

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,34 @@ const (
2020
// FreeHostPort allocates a stable host port for (owner, purpose), persisting it
2121
// inside the lock. A port is considered free only if it is ALL of: not already
2222
// persisted in the ledger, bindable on 127.0.0.1 (advisory), and not published
23-
// by a live tool-managed container. The last check is essential on Docker Desktop
24-
// (macOS/WSL2), where a host bind-test does not reflect the VM's port proxy, so
25-
// the bind-test alone would hand out a port Docker already holds (spec 03/08).
23+
// by a live tool-managed container. The published check is essential on Docker
24+
// Desktop (macOS/WSL2), where a host bind-test does not reflect the VM's port
25+
// proxy, so the bind-test alone would hand out a port Docker already holds (spec
26+
// 03/08). On WSL2 it additionally skips Windows/Hyper-V excluded port ranges (see
27+
// ports_excluded.go) that a Linux-side bind-test cannot see but Docker Desktop's
28+
// Windows-side forward rejects with a 500.
2629
func (m *Manager) FreeHostPort(ctx context.Context, owner, purpose string, base int) (int, error) {
2730
published, err := m.publishedPorts(ctx)
2831
if err != nil {
2932
return 0, err
3033
}
3134
var port int
3235
err = lock.WithLock(ctx, m.LockPath, func() error {
36+
// A port persisted on a previous run may now sit inside a Windows/Hyper-V
37+
// excluded range (those move across reboots on WSL2). AllocatePort returns
38+
// a persisted port verbatim without re-checking, so an excluded one would
39+
// be handed back forever and every publish would fail — release it first so
40+
// a usable port is picked.
41+
if p, ok, e := m.DB.PortFor(owner, purpose); e != nil {
42+
return e
43+
} else if ok && portExcluded(p) {
44+
if e := m.DB.ReleasePort(owner, purpose); e != nil {
45+
return e
46+
}
47+
}
3348
var e error
3449
port, e = m.DB.AllocatePort(owner, purpose, base, base+portRangeSpan, func(p int) bool {
35-
return !published[p] && bindable(p)
50+
return !published[p] && !portExcluded(p) && bindable(p)
3651
})
3752
return e
3853
})
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package workspace
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"os/exec"
7+
"strconv"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/open-source-cloud/devstack/internal/xdg"
13+
)
14+
15+
// netshTimeout bounds the netsh.exe call so a wedged Windows side never hangs
16+
// the CLI; on timeout we fall back to "no exclusions" (the bind-test still runs).
17+
const netshTimeout = 3 * time.Second
18+
19+
// portRange is an inclusive [start,end] host-port range that cannot be published.
20+
type portRange struct{ start, end int }
21+
22+
// netshRunner returns the raw `netsh int ipv4 show excludedportrange` output.
23+
// Overridable in tests; nil in production means "shell out to netsh.exe".
24+
var netshRunner func() string
25+
26+
// wsl2Detect gates the exclusion query to WSL2. A package var so tests can force
27+
// the WSL2 path deterministically on any platform.
28+
var wsl2Detect = xdg.IsWSL2
29+
30+
var (
31+
excludedMu sync.Mutex
32+
excludedComputed bool
33+
excludedCache []portRange
34+
)
35+
36+
// excludedPortRanges returns the host-port ranges that cannot be bound as a
37+
// published Docker port on this host. On WSL2 with Docker Desktop, Windows and
38+
// Hyper-V DYNAMICALLY reserve TCP port ranges (`netsh int ipv4 show
39+
// excludedportrange protocol=tcp`); the ranges change on every Windows reboot.
40+
// A bind-test inside the Linux distro does NOT see them, so Docker Desktop's
41+
// Windows-side port forward fails with:
42+
//
43+
// ports are not available: exposing port TCP 127.0.0.1:X -> 127.0.0.1:0:
44+
// /forwards/expose returned unexpected status: 500
45+
//
46+
// Treating these ranges as unavailable during allocation is what keeps `expose`
47+
// (and any other host-published port) working on WSL2. Non-WSL2 hosts have no
48+
// such exclusions and return nil. Result is cached for this process' lifetime
49+
// (the CLI is short-lived; ranges are stable within a boot).
50+
func excludedPortRanges() []portRange {
51+
excludedMu.Lock()
52+
defer excludedMu.Unlock()
53+
if !excludedComputed {
54+
excludedComputed = true
55+
if wsl2Detect() {
56+
run := netshRunner
57+
if run == nil {
58+
run = runNetshExcluded
59+
}
60+
excludedCache = parseExcludedPortRanges(run())
61+
}
62+
}
63+
return excludedCache
64+
}
65+
66+
// resetExcludedCache clears the memoized ranges so a later call recomputes.
67+
// Used by tests that inject a fake netsh output; a no-op cost in production.
68+
func resetExcludedCache() {
69+
excludedMu.Lock()
70+
excludedComputed = false
71+
excludedCache = nil
72+
excludedMu.Unlock()
73+
}
74+
75+
// runNetshExcluded shells out to the Windows netsh.exe (reachable from WSL2) for
76+
// the TCP excluded-port-range table. A failure (netsh missing, non-Desktop WSL2)
77+
// yields no exclusions rather than an error — the bind-test remains the backstop.
78+
func runNetshExcluded() string {
79+
ctx, cancel := context.WithTimeout(context.Background(), netshTimeout)
80+
defer cancel()
81+
out, err := exec.CommandContext(ctx, "netsh.exe", "int", "ipv4",
82+
"show", "excludedportrange", "protocol=tcp").Output()
83+
if err != nil {
84+
return ""
85+
}
86+
return string(out)
87+
}
88+
89+
// parseExcludedPortRanges extracts inclusive [start,end] pairs from netsh's
90+
// excluded-port-range table. Each data row is two integers (start, end) with an
91+
// optional trailing "*" note; the title, header, and separator lines have no
92+
// leading integer pair. Matching on the "two integers begin the line" shape (not
93+
// on column headings) keeps it locale-independent — netsh localizes its headers.
94+
func parseExcludedPortRanges(text string) []portRange {
95+
var ranges []portRange
96+
sc := bufio.NewScanner(strings.NewReader(text))
97+
for sc.Scan() {
98+
fields := strings.Fields(sc.Text())
99+
if len(fields) < 2 {
100+
continue
101+
}
102+
start, err1 := strconv.Atoi(fields[0])
103+
end, err2 := strconv.Atoi(fields[1])
104+
if err1 != nil || err2 != nil || start <= 0 || end < start {
105+
continue
106+
}
107+
ranges = append(ranges, portRange{start, end})
108+
}
109+
return ranges
110+
}
111+
112+
// portExcluded reports whether p falls inside any host-reserved excluded range.
113+
func portExcluded(p int) bool {
114+
for _, r := range excludedPortRanges() {
115+
if p >= r.start && p <= r.end {
116+
return true
117+
}
118+
}
119+
return false
120+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package workspace
2+
3+
import (
4+
"context"
5+
"testing"
6+
)
7+
8+
// netshSample mirrors real `netsh int ipv4 show excludedportrange` output,
9+
// including the Portuguese localized header (the parser must be locale-agnostic)
10+
// and the trailing "*" note on administered exclusions.
11+
const netshSample = `
12+
Protocolo tcp Intervalos de Exclusão de Porta
13+
14+
Porta Inicial Porta Final
15+
---------- --------
16+
50000 50059 *
17+
54235 54235
18+
58956 59055
19+
59056 59155
20+
21+
* - Exclusões de porta administradas.
22+
`
23+
24+
func TestParseExcludedPortRanges(t *testing.T) {
25+
got := parseExcludedPortRanges(netshSample)
26+
want := []portRange{{50000, 50059}, {54235, 54235}, {58956, 59055}, {59056, 59155}}
27+
if len(got) != len(want) {
28+
t.Fatalf("parsed %d ranges, want %d: %+v", len(got), len(want), got)
29+
}
30+
for i := range want {
31+
if got[i] != want[i] {
32+
t.Errorf("range %d = %+v, want %+v", i, got[i], want[i])
33+
}
34+
}
35+
}
36+
37+
func TestParseExcludedPortRangesIgnoresJunk(t *testing.T) {
38+
if r := parseExcludedPortRanges(""); r != nil {
39+
t.Errorf("empty input yielded %+v", r)
40+
}
41+
// Header-only (no data rows) → no ranges. A reversed/invalid pair is dropped.
42+
if r := parseExcludedPortRanges("Start End\n---- ----\n900 100\n"); len(r) != 0 {
43+
t.Errorf("invalid rows yielded %+v", r)
44+
}
45+
}
46+
47+
// withExcludedRanges forces the WSL2 exclusion path with a fake netsh output for
48+
// one test (deterministic on any platform) and restores + clears the cache after.
49+
func withExcludedRanges(t *testing.T, output string) {
50+
t.Helper()
51+
prevRunner, prevDetect := netshRunner, wsl2Detect
52+
netshRunner = func() string { return output }
53+
wsl2Detect = func() bool { return true }
54+
resetExcludedCache()
55+
t.Cleanup(func() {
56+
netshRunner, wsl2Detect = prevRunner, prevDetect
57+
resetExcludedCache()
58+
})
59+
}
60+
61+
func TestFreeHostPortReallocatesExcludedPersistedPort(t *testing.T) {
62+
withExcludedRanges(t, netshSample)
63+
m := newManager(t, nil)
64+
ctx := context.Background()
65+
66+
// Pre-seed the ledger with a port that lands inside an excluded range,
67+
// simulating an allocation made before Windows reserved that range.
68+
if p, err := m.DB.AllocatePort("minio", "minio-expose", 59000, 59000, nil); err != nil || p != 59000 {
69+
t.Fatalf("seed port: got %d, err %v", p, err)
70+
}
71+
port, err := m.FreeHostPort(ctx, "minio", "minio-expose", 59000)
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
if portExcluded(port) {
76+
t.Fatalf("re-allocated into an excluded range: %d", port)
77+
}
78+
// Stable afterward.
79+
if again, _ := m.FreeHostPort(ctx, "minio", "minio-expose", 59000); again != port {
80+
t.Errorf("port not stable after reallocation: %d vs %d", again, port)
81+
}
82+
}

0 commit comments

Comments
 (0)