Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions docs/spec/serve-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>` 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:
Expand Down Expand Up @@ -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]` |
37 changes: 37 additions & 0 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading