package server_test import ( "context" "testing" "github.com/stretchr/testify/require" "go.uber.org/fx" "sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/server" ) // earlyStopIterations is how many start/stop cycles the race test // runs. The window it aims at is the gap between the OnStart hook // returning and the serving goroutine reaching its first field // access, which is microseconds wide. The race detector reports an // unsynchronised pair whenever it observes one, but it has to observe // one, so a single cycle can miss purely on scheduling. Repetition // makes the observation reliable; the collaborators are built once, // so the cycles themselves are cheap. const earlyStopIterations = 25 // TestEarlyShutdown_NoPanicAndNoRace stops the application // immediately after starting it, before the serving goroutine has // necessarily run at all. // // Two defects live in that window. The OnStart hook returns as soon // as it has spawned the serving goroutine, so fx runs the stop // sequence against a Server whose serving goroutine may not have // executed a single line. cleanShutdown called Shutdown on an // httpServer that goroutine was supposed to assign, which was a nil // dereference on an early SIGTERM; and it read httpServer and // sentryEnabled with nothing ordering those reads against the // goroutine's writes, which is a data race that only surfaces once // something both starts and stops the server. Nothing did before this // test: the listen-failure test never binds, and the router tests // bypass the lifecycle entirely. // // httpServer is now built in New, on the constructing goroutine, so // it is written before any hook exists and can never be nil. // sentryEnabled is atomic. This test is what catches either one // coming back — under -race, which is how the suite runs. func TestEarlyShutdown_NoPanicAndNoRace(t *testing.T) { t.Parallel() // Built once: the collaborators are not what is under test, and // standing up a database per iteration would make repetition too // expensive to be worth having. env := newTestEnv(t) env.cfg.BindAddress = loopbackV4 for range earlyStopIterations { requireStartStopIsClean(t, env) } } // requireStartStopIsClean runs one start/stop cycle with no wait in // between, failing the test if either half errors. // // Each cycle gets a fresh fx app, so the Server under test is // constructed anew every time — that construction is where the // httpServer write now happens, and reusing one Server would test it // only once. func requireStartStopIsClean(t *testing.T, env *testEnv) { t.Helper() env.cfg.Port = freePort(t) app := fx.New( fx.NopLogger, fx.Supply(env.log, env.cfg, env.mw, env.hnd), fx.Provide(globals.New, server.New), fx.Invoke(func(*server.Server) {}), ) startCtx, cancelStart := context.WithTimeout( context.Background(), lifecycleTimeout, ) defer cancelStart() require.NoError(t, app.Start(startCtx)) // No sleep and no readiness wait: stopping while the serving // goroutine is still in flight is the whole point. stopCtx, cancelStop := context.WithTimeout( context.Background(), lifecycleTimeout, ) defer cancelStop() require.NoError(t, app.Stop(stopCtx)) }