Skip to content

Commit 250be6b

Browse files
Copilotmtsfoni
andcommitted
Auto-select next free port when default serve port is taken
Co-authored-by: mtsfoni <80639729+mtsfoni@users.noreply.github.com>
1 parent 2d050e7 commit 250be6b

4 files changed

Lines changed: 164 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## [Unreleased]
44

5+
### Added
6+
- **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.
7+
58
---
69

710
## [v0.8.0] — 2026-03-07

docs/spec/serve-mode.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ The `--port` flag is unchanged — it continues to publish application ports (e.
6868

6969
`--serve-port` is saved to `~/.construct/last-used.json` and replayed by `construct qs`.
7070

71+
## Auto-port fallback (no `--serve-port` given)
72+
73+
When `--serve-port` is **not** specified and the default port (4096) is already
74+
bound on the host, `construct` automatically picks the next free higher port and
75+
prints a yellow diagnostic to stderr:
76+
77+
```
78+
construct: port 4096 is already in use; using port 4097 instead
79+
```
80+
81+
The fallback port is chosen by probing `127.0.0.1:<port>` sequentially from
82+
4096 upward until a free one is found. If `--serve-port` **is** specified
83+
explicitly, no fallback occurs — the user-supplied port is used as-is.
84+
7185
## Local client selection (`--client`)
7286

7387
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
121135
|---|---|
122136
| `docs/spec/serve-mode.md` | This spec |
123137
| `internal/config/lastused.go` | Add `ServePort int` and `Client string` to `LastUsed` |
124-
| `internal/runner/runner.go` | `Config.ServePort`; `Config.Client`; detached container start; `waitForServer`, `runLocalAttach(url, client)`, `runLocalHeadless` helpers; debug mode unchanged |
138+
| `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 |
125139
| `cmd/construct/main.go` | `--serve-port` and `--client` flags, pass to `runner.Config`, save to last-used |
126-
| `internal/runner/runner_test.go` | Tests for `buildServeArgs`, health-poll timeout behaviour, `runLocalAttach` client modes |
140+
| `internal/runner/runner_test.go` | Tests for `buildServeArgs`, health-poll timeout behaviour, `runLocalAttach` client modes, `isPortFree`/`findFreePort` |
127141
| `CHANGELOG.md` | Entry under `[Unreleased]` |

internal/runner/runner.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"encoding/json"
99
"fmt"
1010
"io"
11+
"net"
1112
"net/http"
1213
"os"
1314
"os/exec"
@@ -73,6 +74,29 @@ type Config struct {
7374
// defaultServePort is the port used by opencode serve when Config.ServePort is zero.
7475
const defaultServePort = 4096
7576

77+
// isPortFree reports whether the given TCP port is free on the loopback interface.
78+
func isPortFree(port int) bool {
79+
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
80+
if err != nil {
81+
return false
82+
}
83+
ln.Close()
84+
return true
85+
}
86+
87+
// findFreePort returns the first free TCP port in [start, start+maxPortSearch)
88+
// by probing the loopback interface. It returns 0 if no free port is found
89+
// within the search range.
90+
func findFreePort(start int) int {
91+
const maxPortSearch = 100
92+
for p := start; p < start+maxPortSearch && p <= 65535; p++ {
93+
if isPortFree(p) {
94+
return p
95+
}
96+
}
97+
return 0
98+
}
99+
76100
// servePort returns the effective serve port for the given config.
77101
func servePort(cfg *Config) int {
78102
if cfg.ServePort > 0 {
@@ -216,6 +240,19 @@ func Run(cfg *Config) error {
216240
// 9. Normal mode: start the opencode server detached inside the container,
217241
// wait for it to be ready, then connect a local client.
218242
port := servePort(cfg)
243+
// When the user has not specified a port explicitly, auto-select the next
244+
// free port if the default is already in use on the host.
245+
if cfg.ServePort == 0 {
246+
free := findFreePort(defaultServePort)
247+
if free == 0 {
248+
return fmt.Errorf("no free port found in range %d-%d; use --serve-port to specify a port explicitly", defaultServePort, defaultServePort+99)
249+
}
250+
if free != defaultServePort {
251+
// ANSI yellow on stderr — visible but not alarming.
252+
fmt.Fprintf(os.Stderr, "\033[33mconstruct: port %d is already in use; using port %d instead\033[0m\n", defaultServePort, free)
253+
port = free
254+
}
255+
}
219256
fmt.Printf("construct: launching %s serve in %s container (port %d)…\n", cfg.Tool.Name, cfg.Stack, port)
220257

221258
serverArgs := buildServeArgs(cfg, dindInst, toolImage, sessionID, homVol, authVol, secretsDir, port)

internal/runner/runner_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1764,6 +1764,114 @@ func TestServePort_CustomPort(t *testing.T) {
17641764
}
17651765
}
17661766

1767+
// ---------------------------------------------------------------------------
1768+
// isPortFree / findFreePort tests
1769+
// ---------------------------------------------------------------------------
1770+
1771+
// TestIsPortFree_FreePort verifies that isPortFree returns true for a port
1772+
// that nothing is bound to.
1773+
func TestIsPortFree_FreePort(t *testing.T) {
1774+
// Grab an ephemeral port from the OS, then close the listener before
1775+
// calling isPortFree so the port is genuinely free.
1776+
ln, err := net.Listen("tcp", "127.0.0.1:0")
1777+
if err != nil {
1778+
t.Fatalf("listen: %v", err)
1779+
}
1780+
port := ln.Addr().(*net.TCPAddr).Port
1781+
ln.Close()
1782+
1783+
if !isPortFree(port) {
1784+
t.Errorf("isPortFree(%d) = false, want true for an unbound port", port)
1785+
}
1786+
}
1787+
1788+
// TestIsPortFree_BusyPort verifies that isPortFree returns false when
1789+
// a listener is already bound to the port.
1790+
func TestIsPortFree_BusyPort(t *testing.T) {
1791+
ln, err := net.Listen("tcp", "127.0.0.1:0")
1792+
if err != nil {
1793+
t.Fatalf("listen: %v", err)
1794+
}
1795+
defer ln.Close()
1796+
port := ln.Addr().(*net.TCPAddr).Port
1797+
1798+
if isPortFree(port) {
1799+
t.Errorf("isPortFree(%d) = true, want false while a listener holds the port", port)
1800+
}
1801+
}
1802+
1803+
// TestFindFreePort_ReturnsStartWhenFree verifies that findFreePort returns the
1804+
// start port itself when that port is not in use.
1805+
func TestFindFreePort_ReturnsStartWhenFree(t *testing.T) {
1806+
// Use an ephemeral port that the OS just freed.
1807+
ln, err := net.Listen("tcp", "127.0.0.1:0")
1808+
if err != nil {
1809+
t.Fatalf("listen: %v", err)
1810+
}
1811+
port := ln.Addr().(*net.TCPAddr).Port
1812+
ln.Close()
1813+
1814+
got := findFreePort(port)
1815+
if got != port {
1816+
t.Errorf("findFreePort(%d) = %d, want %d when port is free", port, got, port)
1817+
}
1818+
}
1819+
1820+
// TestFindFreePort_SkipsBusyPort verifies that findFreePort skips over a busy
1821+
// port and returns the next free one.
1822+
func TestFindFreePort_SkipsBusyPort(t *testing.T) {
1823+
// Bind a listener on the first port so it is unavailable.
1824+
ln, err := net.Listen("tcp", "127.0.0.1:0")
1825+
if err != nil {
1826+
t.Fatalf("listen: %v", err)
1827+
}
1828+
defer ln.Close()
1829+
busyPort := ln.Addr().(*net.TCPAddr).Port
1830+
1831+
got := findFreePort(busyPort)
1832+
if got == 0 {
1833+
t.Fatalf("findFreePort(%d) = 0; expected a free port to be found", busyPort)
1834+
}
1835+
if got <= busyPort {
1836+
t.Errorf("findFreePort(%d) = %d; want a port > %d (busy port skipped)", busyPort, got, busyPort)
1837+
}
1838+
// The returned port must actually be free.
1839+
if !isPortFree(got) {
1840+
t.Errorf("findFreePort returned port %d, but isPortFree(%d) = false", got, got)
1841+
}
1842+
}
1843+
1844+
// TestFindFreePort_ReturnsZeroWhenRangeExhausted verifies that findFreePort
1845+
// returns 0 when all ports in the search range are occupied.
1846+
func TestFindFreePort_ReturnsZeroWhenRangeExhausted(t *testing.T) {
1847+
// Bind listeners on 100 consecutive ports starting at startPort.
1848+
// We use high ephemeral ports to avoid system conflicts.
1849+
const startPort = 59900
1850+
const count = 100
1851+
listeners := make([]net.Listener, 0, count)
1852+
for i := 0; i < count; i++ {
1853+
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", startPort+i))
1854+
if err != nil {
1855+
// If we can't bind all 100 ports (e.g. some already taken), skip the test.
1856+
for _, l := range listeners {
1857+
l.Close()
1858+
}
1859+
t.Skipf("could not bind port %d: %v", startPort+i, err)
1860+
}
1861+
listeners = append(listeners, ln)
1862+
}
1863+
defer func() {
1864+
for _, l := range listeners {
1865+
l.Close()
1866+
}
1867+
}()
1868+
1869+
got := findFreePort(startPort)
1870+
if got != 0 {
1871+
t.Errorf("findFreePort(%d) = %d, want 0 when all ports in range are busy", startPort, got)
1872+
}
1873+
}
1874+
17671875
// ---------------------------------------------------------------------------
17681876
// buildServeArgs tests
17691877
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)