From 2d050e76a71a14ae5b9b071cd93b96b4476aa044 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:04:51 +0000 Subject: [PATCH 1/2] Initial plan From 250be6b07fe00b830cbc1d27f33b4c133ac533c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 07:12:24 +0000 Subject: [PATCH 2/2] Auto-select next free port when default serve port is taken Co-authored-by: mtsfoni <80639729+mtsfoni@users.noreply.github.com> --- CHANGELOG.md | 3 + docs/spec/serve-mode.md | 18 +++++- internal/runner/runner.go | 37 +++++++++++ internal/runner/runner_test.go | 108 +++++++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1812edc..7b17893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- **Auto-port fallback for serve mode** — when `--serve-port` is not specified and the default port (4096) is already in use on the host, `construct` now automatically picks the next free higher port instead of failing. A yellow diagnostic is printed to stderr: `construct: port 4096 is already in use; using port 4097 instead`. If `--serve-port` is specified explicitly, no fallback occurs. + --- ## [v0.8.0] — 2026-03-07 diff --git a/docs/spec/serve-mode.md b/docs/spec/serve-mode.md index 2844a37..faeb626 100644 --- a/docs/spec/serve-mode.md +++ b/docs/spec/serve-mode.md @@ -68,6 +68,20 @@ The `--port` flag is unchanged — it continues to publish application ports (e. `--serve-port` is saved to `~/.construct/last-used.json` and replayed by `construct qs`. +## Auto-port fallback (no `--serve-port` given) + +When `--serve-port` is **not** specified and the default port (4096) is already +bound on the host, `construct` automatically picks the next free higher port and +prints a yellow diagnostic to stderr: + +``` +construct: port 4096 is already in use; using port 4097 instead +``` + +The fallback port is chosen by probing `127.0.0.1:` sequentially from +4096 upward until a free one is found. If `--serve-port` **is** specified +explicitly, no fallback occurs — the user-supplied port is used as-is. + ## Local client selection (`--client`) The `--client` flag controls how the host connects to the opencode server once it is ready: @@ -121,7 +135,7 @@ The opencode server is bound to `0.0.0.0` inside the container so the host can r |---|---| | `docs/spec/serve-mode.md` | This spec | | `internal/config/lastused.go` | Add `ServePort int` and `Client string` to `LastUsed` | -| `internal/runner/runner.go` | `Config.ServePort`; `Config.Client`; detached container start; `waitForServer`, `runLocalAttach(url, client)`, `runLocalHeadless` helpers; debug mode unchanged | +| `internal/runner/runner.go` | `Config.ServePort`; `Config.Client`; detached container start; `waitForServer`, `runLocalAttach(url, client)`, `runLocalHeadless` helpers; debug mode unchanged; `isPortFree`/`findFreePort` for auto-port fallback | | `cmd/construct/main.go` | `--serve-port` and `--client` flags, pass to `runner.Config`, save to last-used | -| `internal/runner/runner_test.go` | Tests for `buildServeArgs`, health-poll timeout behaviour, `runLocalAttach` client modes | +| `internal/runner/runner_test.go` | Tests for `buildServeArgs`, health-poll timeout behaviour, `runLocalAttach` client modes, `isPortFree`/`findFreePort` | | `CHANGELOG.md` | Entry under `[Unreleased]` | diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8d47a63..4d3fc06 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "os" "os/exec" @@ -73,6 +74,29 @@ type Config struct { // defaultServePort is the port used by opencode serve when Config.ServePort is zero. const defaultServePort = 4096 +// isPortFree reports whether the given TCP port is free on the loopback interface. +func isPortFree(port int) bool { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + ln.Close() + return true +} + +// findFreePort returns the first free TCP port in [start, start+maxPortSearch) +// by probing the loopback interface. It returns 0 if no free port is found +// within the search range. +func findFreePort(start int) int { + const maxPortSearch = 100 + for p := start; p < start+maxPortSearch && p <= 65535; p++ { + if isPortFree(p) { + return p + } + } + return 0 +} + // servePort returns the effective serve port for the given config. func servePort(cfg *Config) int { if cfg.ServePort > 0 { @@ -216,6 +240,19 @@ func Run(cfg *Config) error { // 9. Normal mode: start the opencode server detached inside the container, // wait for it to be ready, then connect a local client. port := servePort(cfg) + // When the user has not specified a port explicitly, auto-select the next + // free port if the default is already in use on the host. + if cfg.ServePort == 0 { + free := findFreePort(defaultServePort) + if free == 0 { + return fmt.Errorf("no free port found in range %d-%d; use --serve-port to specify a port explicitly", defaultServePort, defaultServePort+99) + } + if free != defaultServePort { + // ANSI yellow on stderr — visible but not alarming. + fmt.Fprintf(os.Stderr, "\033[33mconstruct: port %d is already in use; using port %d instead\033[0m\n", defaultServePort, free) + port = free + } + } fmt.Printf("construct: launching %s serve in %s container (port %d)…\n", cfg.Tool.Name, cfg.Stack, port) serverArgs := buildServeArgs(cfg, dindInst, toolImage, sessionID, homVol, authVol, secretsDir, port) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 3e13593..671c5a6 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1764,6 +1764,114 @@ func TestServePort_CustomPort(t *testing.T) { } } +// --------------------------------------------------------------------------- +// isPortFree / findFreePort tests +// --------------------------------------------------------------------------- + +// TestIsPortFree_FreePort verifies that isPortFree returns true for a port +// that nothing is bound to. +func TestIsPortFree_FreePort(t *testing.T) { + // Grab an ephemeral port from the OS, then close the listener before + // calling isPortFree so the port is genuinely free. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + if !isPortFree(port) { + t.Errorf("isPortFree(%d) = false, want true for an unbound port", port) + } +} + +// TestIsPortFree_BusyPort verifies that isPortFree returns false when +// a listener is already bound to the port. +func TestIsPortFree_BusyPort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + if isPortFree(port) { + t.Errorf("isPortFree(%d) = true, want false while a listener holds the port", port) + } +} + +// TestFindFreePort_ReturnsStartWhenFree verifies that findFreePort returns the +// start port itself when that port is not in use. +func TestFindFreePort_ReturnsStartWhenFree(t *testing.T) { + // Use an ephemeral port that the OS just freed. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + ln.Close() + + got := findFreePort(port) + if got != port { + t.Errorf("findFreePort(%d) = %d, want %d when port is free", port, got, port) + } +} + +// TestFindFreePort_SkipsBusyPort verifies that findFreePort skips over a busy +// port and returns the next free one. +func TestFindFreePort_SkipsBusyPort(t *testing.T) { + // Bind a listener on the first port so it is unavailable. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + busyPort := ln.Addr().(*net.TCPAddr).Port + + got := findFreePort(busyPort) + if got == 0 { + t.Fatalf("findFreePort(%d) = 0; expected a free port to be found", busyPort) + } + if got <= busyPort { + t.Errorf("findFreePort(%d) = %d; want a port > %d (busy port skipped)", busyPort, got, busyPort) + } + // The returned port must actually be free. + if !isPortFree(got) { + t.Errorf("findFreePort returned port %d, but isPortFree(%d) = false", got, got) + } +} + +// TestFindFreePort_ReturnsZeroWhenRangeExhausted verifies that findFreePort +// returns 0 when all ports in the search range are occupied. +func TestFindFreePort_ReturnsZeroWhenRangeExhausted(t *testing.T) { + // Bind listeners on 100 consecutive ports starting at startPort. + // We use high ephemeral ports to avoid system conflicts. + const startPort = 59900 + const count = 100 + listeners := make([]net.Listener, 0, count) + for i := 0; i < count; i++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", startPort+i)) + if err != nil { + // If we can't bind all 100 ports (e.g. some already taken), skip the test. + for _, l := range listeners { + l.Close() + } + t.Skipf("could not bind port %d: %v", startPort+i, err) + } + listeners = append(listeners, ln) + } + defer func() { + for _, l := range listeners { + l.Close() + } + }() + + got := findFreePort(startPort) + if got != 0 { + t.Errorf("findFreePort(%d) = %d, want 0 when all ports in range are busy", startPort, got) + } +} + // --------------------------------------------------------------------------- // buildServeArgs tests // ---------------------------------------------------------------------------