diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index 05991423d6..46d465aff2 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -230,7 +230,7 @@ func migrateBaseDatabase(ctx context.Context, config pgconn.Config, migrations [ } func diffWithStream(ctx context.Context, env []string, script string, stdout io.Writer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := utils.EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/utils/edgeruntime.go b/apps/cli-go/internal/utils/edgeruntime.go index 06a42b464c..d7070f3f9e 100644 --- a/apps/cli-go/internal/utils/edgeruntime.go +++ b/apps/cli-go/internal/utils/edgeruntime.go @@ -3,6 +3,8 @@ package utils import ( "bytes" "context" + "fmt" + "net" "strings" "github.com/docker/docker/api/types/container" @@ -11,10 +13,35 @@ import ( "github.com/spf13/viper" ) +// getFreeHostPort asks the OS for an unused TCP port on the host. +func getFreeHostPort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, errors.Errorf("failed to allocate free port: %w", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +// EdgeRuntimeStartCmd builds the base command for launching a one-shot Edge +// Runtime script. The runtime's HTTP listener is bound to a free host port so +// concurrent or leftover containers (which share the host network namespace +// because diff containers run with NetworkMode=host) don't collide on the +// edge-runtime default port, which surfaces as "Address already in use (os +// error 98)". See https://github.com/supabase/cli/issues/5407. +func EdgeRuntimeStartCmd() []string { + cmd := []string{"edge-runtime", "start", "--main-service=."} + // Skip the flag on the rare allocation failure to preserve prior behavior. + if port, err := getFreeHostPort(); err == nil { + cmd = append(cmd, fmt.Sprintf("--port=%d", port)) + } + return cmd +} + // RunEdgeRuntimeScript executes a TypeScript program inside the configured Edge // Runtime container and streams stdout/stderr back to the caller. func RunEdgeRuntimeScript(ctx context.Context, env []string, script string, binds []string, errPrefix string, stdout, stderr *bytes.Buffer) error { - cmd := []string{"edge-runtime", "start", "--main-service=."} + cmd := EdgeRuntimeStartCmd() if viper.GetBool("DEBUG") { cmd = append(cmd, "--verbose") } diff --git a/apps/cli-go/internal/utils/edgeruntime_test.go b/apps/cli-go/internal/utils/edgeruntime_test.go new file mode 100644 index 0000000000..8bed5b0974 --- /dev/null +++ b/apps/cli-go/internal/utils/edgeruntime_test.go @@ -0,0 +1,47 @@ +package utils + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEdgeRuntimeStartCmd(t *testing.T) { + t.Run("binds an explicit free port", func(t *testing.T) { + cmd := EdgeRuntimeStartCmd() + // Base command must always be present. + assert.Equal(t, []string{"edge-runtime", "start", "--main-service=."}, cmd[:3]) + // A --port flag avoids collisions on the edge-runtime default port (#5407). + var portFlag string + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + portFlag = arg + } + } + require.NotEmpty(t, portFlag, "expected a --port flag to be set") + port, err := strconv.Atoi(strings.TrimPrefix(portFlag, "--port=")) + require.NoError(t, err) + assert.Greater(t, port, 0) + assert.LessOrEqual(t, port, 65535) + }) + + t.Run("allocates a distinct port per invocation", func(t *testing.T) { + first := getPortArg(t, EdgeRuntimeStartCmd()) + second := getPortArg(t, EdgeRuntimeStartCmd()) + assert.NotEqual(t, first, second) + }) +} + +func getPortArg(t *testing.T, cmd []string) string { + t.Helper() + for _, arg := range cmd { + if strings.HasPrefix(arg, "--port=") { + return arg + } + } + require.FailNow(t, "missing --port flag") + return "" +}