Skip to content

Commit 83e5b94

Browse files
authored
fix(cli): bind a free port for edge-runtime diff containers (#5424)
## What changed The schema diff path (`supabase db pull` and friends) executes one-shot scripts — migra, pg-delta, pgcache — by running `edge-runtime start --main-service=.` inside a container. Both call sites (`RunEdgeRuntimeScript` in `internal/utils/edgeruntime.go` and `diffWithStream` in `internal/db/diff/diff.go`) launched it with `NetworkMode: host` but **without** a `--port` flag. `edge-runtime start` is an HTTP server and always binds a TCP listener. With no explicit port it bound the edge-runtime **default** port, and with host networking that bind landed directly in the host (Docker VM) network namespace. When the port was already taken — a leftover diff container from an interrupted run, the local stack, or anything else on that port — the bind failed and the container exited 1. This change adds a shared `EdgeRuntimeStartCmd` helper that allocates a free host port and passes it as `--port`, used by both call sites, so concurrent or leftover one-shot containers no longer contend for the default port. On the rare port-allocation failure it falls back to the previous portless command. ## Why Reported in #5407: `supabase db pull` on Windows fails at "Diffing schemas..." with `Error: Address already in use (os error 98)`. Host networking on Docker Desktop (Windows/macOS) shares the VM namespace and makes the default-port collision far more likely. `functions serve` was never affected because it already passes an explicit `--port` (`serve.go:190`). ## Reviewer notes - Covers all diff engines that go through `RunEdgeRuntimeScript`: migra, pg-delta (×3), pgcache, apply — plus the streaming `diffWithStream`. - With Docker Desktop host networking the port is probed on the real host while the bind happens in the VM namespace, so a probed-free port isn't strictly guaranteed free in the VM. Moving off the single shared default to a random ephemeral port removes virtually all real-world collisions; a fully bulletproof fix would be moving these containers to bridge networking with port mapping (like `serve.go`), which can follow separately. Fixes #5407
1 parent 39bf0e7 commit 83e5b94

3 files changed

Lines changed: 76 additions & 2 deletions

File tree

apps/cli-go/internal/db/diff/diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations [
230230
}
231231

232232
func diffWithStream(ctx context.Context, env []string, script string, stdout io.Writer) error {
233-
cmd := []string{"edge-runtime", "start", "--main-service=."}
233+
cmd := utils.EdgeRuntimeStartCmd()
234234
if viper.GetBool("DEBUG") {
235235
cmd = append(cmd, "--verbose")
236236
}

apps/cli-go/internal/utils/edgeruntime.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package utils
33
import (
44
"bytes"
55
"context"
6+
"fmt"
7+
"net"
68
"strings"
79

810
"github.com/docker/docker/api/types/container"
@@ -11,10 +13,35 @@ import (
1113
"github.com/spf13/viper"
1214
)
1315

16+
// getFreeHostPort asks the OS for an unused TCP port on the host.
17+
func getFreeHostPort() (int, error) {
18+
listener, err := net.Listen("tcp", "127.0.0.1:0")
19+
if err != nil {
20+
return 0, errors.Errorf("failed to allocate free port: %w", err)
21+
}
22+
defer listener.Close()
23+
return listener.Addr().(*net.TCPAddr).Port, nil
24+
}
25+
26+
// EdgeRuntimeStartCmd builds the base command for launching a one-shot Edge
27+
// Runtime script. The runtime's HTTP listener is bound to a free host port so
28+
// concurrent or leftover containers (which share the host network namespace
29+
// because diff containers run with NetworkMode=host) don't collide on the
30+
// edge-runtime default port, which surfaces as "Address already in use (os
31+
// error 98)". See https://github.com/supabase/cli/issues/5407.
32+
func EdgeRuntimeStartCmd() []string {
33+
cmd := []string{"edge-runtime", "start", "--main-service=."}
34+
// Skip the flag on the rare allocation failure to preserve prior behavior.
35+
if port, err := getFreeHostPort(); err == nil {
36+
cmd = append(cmd, fmt.Sprintf("--port=%d", port))
37+
}
38+
return cmd
39+
}
40+
1441
// RunEdgeRuntimeScript executes a TypeScript program inside the configured Edge
1542
// Runtime container and streams stdout/stderr back to the caller.
1643
func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, binds []string, errPrefix string, stdout, stderr *bytes.Buffer) error {
17-
cmd := []string{"edge-runtime", "start", "--main-service=."}
44+
cmd := EdgeRuntimeStartCmd()
1845
if viper.GetBool("DEBUG") {
1946
cmd = append(cmd, "--verbose")
2047
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package utils
2+
3+
import (
4+
"strconv"
5+
"strings"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestEdgeRuntimeStartCmd(t *testing.T) {
13+
t.Run("binds an explicit free port", func(t *testing.T) {
14+
cmd := EdgeRuntimeStartCmd()
15+
// Base command must always be present.
16+
assert.Equal(t, []string{"edge-runtime", "start", "--main-service=."}, cmd[:3])
17+
// A --port flag avoids collisions on the edge-runtime default port (#5407).
18+
var portFlag string
19+
for _, arg := range cmd {
20+
if strings.HasPrefix(arg, "--port=") {
21+
portFlag = arg
22+
}
23+
}
24+
require.NotEmpty(t, portFlag, "expected a --port flag to be set")
25+
port, err := strconv.Atoi(strings.TrimPrefix(portFlag, "--port="))
26+
require.NoError(t, err)
27+
assert.Greater(t, port, 0)
28+
assert.LessOrEqual(t, port, 65535)
29+
})
30+
31+
t.Run("allocates a distinct port per invocation", func(t *testing.T) {
32+
first := getPortArg(t, EdgeRuntimeStartCmd())
33+
second := getPortArg(t, EdgeRuntimeStartCmd())
34+
assert.NotEqual(t, first, second)
35+
})
36+
}
37+
38+
func getPortArg(t *testing.T, cmd []string) string {
39+
t.Helper()
40+
for _, arg := range cmd {
41+
if strings.HasPrefix(arg, "--port=") {
42+
return arg
43+
}
44+
}
45+
require.FailNow(t, "missing --port flag")
46+
return ""
47+
}

0 commit comments

Comments
 (0)