Files
coder/coderd/x/chatd/chatd_debug.go
T
Hugo Dutka 684d904c00 fix(coderd/x/chatd): resolve inflight race (#26460)
Using `WaitGroup.Go` must be synchronized with `WaitGroup.Wait`
according to [go docs](https://pkg.go.dev/sync#WaitGroup.Go):

> If the WaitGroup is empty, Go must happen before a
[WaitGroup.Wait](https://pkg.go.dev/sync#WaitGroup.Wait).

There were a couple of places in chatd that violated this principle.
This was caught as a data race in
https://github.com/coder/internal/issues/1599. This PR ensures that all
functions that spawn inflight goroutines synchronize with each other.

I also noticed that inflight goroutines may be spawned after the server
is closed, which was surprising and looked like a bug. This PR therefore
also introduces a mechanism that disallows spawning inflight goroutines
after the server is closed, and ensures that any code that tries doing
it logs an error.

Closes https://github.com/coder/internal/issues/1599.
2026-06-17 18:29:43 +02:00

144 lines
4.1 KiB
Go

package chatd
import (
"context"
"time"
"charm.land/fantasy"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/x/chatd/chatdebug"
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
)
const (
debugCleanupRetryDelay = 500 * time.Millisecond
debugCleanupAttempts = 3
debugCleanupTimeout = 5 * time.Second
// debugCreateRunTimeout caps how long a CreateRun insert can
// block the caller's critical path. Debug persistence is
// best-effort, so the turn proceeds without debug rows if the
// DB is slow or locked. Matches the manual-title budget.
debugCreateRunTimeout = 5 * time.Second
// debugFinalizeTimeout caps best-effort debug run finalization
// outside the runner's canceled context.
debugFinalizeTimeout = 5 * time.Second
// debugCleanupClockSkew gives cleanup cutoffs tolerance for cross-
// replica clock drift. The cutoff is sampled from the DB
// (updated_at returned by the status transition), and
// chat_debug_runs.started_at is stamped by whatever replica
// processes the replacement turn. If that replica's clock lags
// the DB, its started_at can land behind a commit-time cutoff
// even though the insert physically happened after commit.
// Subtracting this buffer ensures the fast retry path cannot
// delete replacement rows when clocks drift by up to this
// amount; rows within the buffer survive the fast cleanup but
// are still finalized (and eligible for stale-sweep cleanup) by
// the existing FinalizeStale background loop.
debugCleanupClockSkew = 30 * time.Second
)
func (p *Server) debugService() *chatdebug.Service {
if p == nil {
return nil
}
if p.debugSvcFactory == nil {
return p.debugSvc
}
p.debugSvcInit.Do(func() {
p.debugSvc = p.debugSvcFactory()
p.debugSvcReady.Store(p.debugSvc != nil)
})
return p.debugSvc
}
func (p *Server) existingDebugService() *chatdebug.Service {
if p == nil {
return nil
}
if p.debugSvcFactory == nil {
return p.debugSvc
}
if !p.debugSvcReady.Load() {
return nil
}
return p.debugSvc
}
func (p *Server) scheduleDebugCleanup(
ctx context.Context,
logMessage string,
fields []slog.Field,
cleanup func(context.Context, *chatdebug.Service) error,
) {
debugSvc := p.debugService()
if debugSvc == nil {
return
}
if err := p.goInflight(func() {
cleanupCtx := context.WithoutCancel(ctx)
for attempt := 0; attempt < debugCleanupAttempts; attempt++ {
if attempt > 0 {
timer := p.clock.NewTimer(debugCleanupRetryDelay, "chatd", "debug_cleanup")
<-timer.C
}
passCtx, cancel := context.WithTimeout(cleanupCtx, debugCleanupTimeout)
err := cleanup(passCtx, debugSvc)
cancel()
if err == nil {
return
}
logFields := append([]slog.Field{
slog.F("attempt", attempt+1),
slog.F("max_attempts", debugCleanupAttempts),
}, fields...)
logFields = append(logFields, slog.Error(err))
p.logger.Warn(cleanupCtx, logMessage, logFields...)
}
}); err != nil {
logFields := append([]slog.Field{slog.F("cleanup", logMessage)}, fields...)
logFields = append(logFields, slog.Error(err))
p.logger.Error(context.WithoutCancel(ctx), "failed to schedule chat debug cleanup", logFields...)
}
}
func (p *Server) newDebugAwareModel(
ctx context.Context,
req modelClientRequest,
route resolvedModelRoute,
opts modelBuildOptions,
) (fantasy.LanguageModel, bool, error) {
providerHint, err := route.providerHint()
if err != nil {
return nil, false, err
}
provider, resolvedModel, err := chatprovider.ResolveModelWithProviderHint(req.ModelName, providerHint)
if err != nil {
return nil, false, err
}
route = route.withProviderHint(provider)
req.ModelName = resolvedModel
debugSvc := p.debugService()
debugEnabled := debugSvc != nil && debugSvc.IsEnabled(ctx, req.Chat.ID, req.Chat.OwnerID)
opts.RecordHTTP = debugEnabled
model, err := p.newModel(ctx, req, route, opts)
if err != nil {
return nil, debugEnabled, err
}
if !debugEnabled {
return model, false, nil
}
return chatdebug.WrapModel(model, debugSvc, chatdebug.RecorderOptions{
ChatID: req.Chat.ID,
OwnerID: req.Chat.OwnerID,
Provider: provider,
Model: resolvedModel,
}), true, nil
}