check / check (push) Waiting to run
sneak ruled the race detector out (#538 (comment)), and on Linux it was the only reason the tests needed cgo. The test phase and the browser test binary now build with CGO_ENABLED=0 and without -race, so no test binary compiles mattn's C SQLite. The test phase keeps the builder stage's Debian Go image. Its comments, the README and the code comments that said the suite runs under -race now say what is true, and the README notes the exception to the vendored REPO_POLICIES.md. Model: opus-5-5
91 lines
3.1 KiB
Go
91 lines
3.1 KiB
Go
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 test below
|
|
// 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, so a single cycle can miss it
|
|
// purely on scheduling. Repetition makes hitting it 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 catches the nil dereference
|
|
// coming back, as a panic. The suite runs without Go's race
|
|
// detector, so the data race coming back would go unreported here.
|
|
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))
|
|
}
|