From c4233e3e053fdeb45e708254f1a8eba44bae5d65 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 10 Oct 2026 10:56:24 +0200 Subject: [PATCH] fix(test): store tests take an OS-assigned free port, not a PID-derived one PID%1000 collided with a concurrent job on the shared CI host (run on e946709: process already listening on port 54000). freePort asks the OS; Start is retried up to 3 times if the port is taken in between. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM --- internal/adapters/store/port_test.go | 19 ++++++++ internal/adapters/store/store_test.go | 63 +++++++++++++++++++-------- 2 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 internal/adapters/store/port_test.go diff --git a/internal/adapters/store/port_test.go b/internal/adapters/store/port_test.go new file mode 100644 index 0000000..565d2d2 --- /dev/null +++ b/internal/adapters/store/port_test.go @@ -0,0 +1,19 @@ +package store_test + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestMain's Postgres already holds a port. A second instance started in the +// same process must still come up, i.e. the port is not derived from something +// two starters share (the PID). Observes: startTestPostgres succeeds while +// another instance from this process is listening. +func TestStartTestPostgres_SecondInstanceStartsWhileFirstHoldsItsPort(t *testing.T) { + pg, _, rt, err := startTestPostgres(fmt.Sprintf("tapir-epg-store-second-%d", os.Getpid())) + require.NoError(t, err) + t.Cleanup(func() { _ = pg.Stop(); _ = os.RemoveAll(rt) }) +} diff --git a/internal/adapters/store/store_test.go b/internal/adapters/store/store_test.go index 44341cf..272ed62 100644 --- a/internal/adapters/store/store_test.go +++ b/internal/adapters/store/store_test.go @@ -3,6 +3,7 @@ package store_test import ( "context" "fmt" + "net" "os" "path/filepath" "testing" @@ -25,27 +26,15 @@ var _ ports.Sink = (*store.Store)(nil) var dsn string func TestMain(m *testing.M) { - // Port + runtime/data dirs are per-process (PID-derived) so two concurrent - // `go test` invocations — e.g. a push-run and a tag-run firing together in CI — - // don't collide on a fixed port or a shared data dir (which silently failed - // both runs). CachePath is shared so the PG archive is downloaded once, not - // per process. Base 54000 keeps this package's range distinct from web's. - port := uint32(54000 + os.Getpid()%1000) - dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) - - rt := filepath.Join(os.TempDir(), fmt.Sprintf("tapir-epg-store-%d", os.Getpid())) - pg := embeddedpostgres.NewDatabase( - embeddedpostgres.DefaultConfig(). - Port(port). - RuntimePath(rt). - DataPath(filepath.Join(rt, "data")). - BinariesPath(filepath.Join(rt, "bin")). - CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")), - ) - if err := pg.Start(); err != nil { + // Runtime/data dirs are per-process (PID-named) and the port is OS-assigned, + // so concurrent `go test` runs on a shared CI host don't collide on either. + // CachePath is shared so the PG archive is downloaded once, not per process. + pg, port, rt, err := startTestPostgres(fmt.Sprintf("tapir-epg-store-%d", os.Getpid())) + if err != nil { fmt.Fprintf(os.Stderr, "embedded-postgres start: %v\n", err) os.Exit(1) } + dsn = fmt.Sprintf("postgres://postgres:postgres@localhost:%d/postgres?sslmode=disable", port) code := m.Run() @@ -56,6 +45,44 @@ func TestMain(m *testing.M) { os.Exit(code) } +// freePort asks the OS for an unused TCP port. The port is released before +// return, so another process can grab it first; startTestPostgres retries. +func freePort() (uint32, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("listen for free port: %w", err) + } + defer func() { _ = l.Close() }() + return uint32(l.Addr().(*net.TCPAddr).Port), nil +} + +// startTestPostgres starts an embedded Postgres on an OS-assigned free port and +// returns it with that port and its runtime dir (caller removes it). A port +// taken between freePort and Start (shared CI host) is retried with a new one. +func startTestPostgres(name string) (*embeddedpostgres.EmbeddedPostgres, uint32, string, error) { + rt := filepath.Join(os.TempDir(), name) + var lastErr error + for range 3 { + port, err := freePort() + if err != nil { + return nil, 0, rt, err + } + pg := embeddedpostgres.NewDatabase( + embeddedpostgres.DefaultConfig(). + Port(port). + RuntimePath(rt). + DataPath(filepath.Join(rt, "data")). + BinariesPath(filepath.Join(rt, "bin")). + CachePath(filepath.Join(os.TempDir(), "tapir-epg-cache")), + ) + if lastErr = pg.Start(); lastErr == nil { + return pg, port, rt, nil + } + lastErr = fmt.Errorf("start on port %d: %w", port, lastErr) + } + return nil, 0, rt, lastErr +} + // uuids — fixed so tests are deterministic. user_id/video_id are UUID columns. const ( userA = "11111111-1111-1111-1111-111111111111"