diff --git a/cli/server.go b/cli/server.go index c8ff2357f5..3fed22aeb5 100644 --- a/cli/server.go +++ b/cli/server.go @@ -63,6 +63,7 @@ import ( "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/cli/config" "github.com/coder/coder/v2/coderd" + "github.com/coder/coder/v2/coderd/aibridged" "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/awsiamrds" @@ -1014,6 +1015,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if err != nil { return xerrors.Errorf("create coder API: %w", err) } + var aibridgeDaemon *aibridged.Server // Both seed (writes) and build (reads) of AI providers need // options.Database to be dbcrypt-wrapped, which only happens @@ -1044,7 +1046,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. if err != nil { return xerrors.Errorf("build AI providers: %w", err) } - aibridgeDaemon, err := newAIBridgeDaemon(coderAPI, aibridgeProviders) + aibridgeDaemon, err = newAIBridgeDaemon(coderAPI, aibridgeProviders) if err != nil { return xerrors.Errorf("create aibridged: %w", err) } @@ -1310,6 +1312,11 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } wg.Wait() + // The in-memory aibridge server participates in the websocket + // wait group, so close its client before waiting for that group. + if aibridgeDaemon != nil { + _ = aibridgeDaemon.Close() + } cliui.Info(inv.Stdout, "Waiting for WebSocket connections to close..."+"\n") _ = coderAPICloser.Close() cliui.Info(inv.Stdout, "Done waiting for WebSocket connections"+"\n") diff --git a/cli/server_test.go b/cli/server_test.go index 5215eeb08c..89e0ba7048 100644 --- a/cli/server_test.go +++ b/cli/server_test.go @@ -2184,6 +2184,53 @@ func TestServer_InterruptShutdown(t *testing.T) { require.NoError(t, err) } +// TestServer_AIGatewayShutdownOrdering is a regression test for a shutdown +// ordering bug. The in-memory AI Gateway daemon registers itself with the +// API WebsocketWaitGroup, so it must be closed before coderAPICloser.Close() +// waits on that group. If it isn't, API.Close() blocks for the full 10s +// WebsocketWaitGroup timeout, logs "websocket shutdown timed out after 10 +// seconds", and keeps heavy server-test state live for an extra 10s. On +// Windows test-go-pg this extra shutdown tail overlapped across concurrent +// package binaries and OOMed the runner. +func TestServer_AIGatewayShutdownOrdering(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(testutil.Context(t, testutil.WaitLong)) + defer cancel() + + inv, cfg := clitest.New(t, + "server", + dbArg(t), + "--http-address", ":0", + "--access-url", "http://example.com", + "--cache-dir", t.TempDir(), + // Explicit so the test catches the regression even if the + // default for ai-gateway-enabled is ever flipped back to false. + "--ai-gateway-enabled=true", + ) + + serverErr := make(chan error, 1) + go func() { + serverErr <- inv.WithContext(ctx).Run() + }() + + // Wait for the server to come up so the in-memory AI Gateway daemon + // is registered with the API and the WebsocketWaitGroup is nonzero. + _ = waitAccessURL(t, cfg) + + // The WebsocketWaitGroup timeout in coderd.API.Close() is hard coded + // to 10s, so any value comfortably below 10s catches the regression + // while leaving headroom for slow CI runners. + shutdownStart := time.Now() + cancel() + if err := <-serverErr; err != nil { + require.ErrorIs(t, err, context.Canceled) + } + require.Less(t, time.Since(shutdownStart), 8*time.Second, + "graceful shutdown took too long; the in-memory AI Gateway daemon is "+ + "likely not being closed before coderAPICloser.Close()") +} + func TestServer_GracefulShutdown(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" {