mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix: resolve chat title update race conditions and improve resilience (#22450)
## Problem
Chat titles sometimes don't update in the UI. The generated AI title
gets stuck as the fallback (first 6 words of the message) even though
the backend successfully generates a proper title.
## Root Causes
### 1. Cancelable context used during cleanup DB read (P0)
In `processChat`, the deferred cleanup re-reads the chat from the DB to
pick up the AI-generated title for the `status_change` pubsub event. But
it used the cancelable `ctx` instead of `cleanupCtx`:
```go
// Before — ctx may already be canceled here
if freshChat, readErr := p.db.GetChatByID(ctx, chat.ID); readErr == nil {
```
When the context is canceled, the DB read fails silently and the
`status_change` event carries the stale fallback title.
### 2. Title goroutine not tracked by inflight WaitGroup (P2)
The `maybeGenerateChatTitle` goroutine was fire-and-forget — not tracked
by `p.inflight`. During graceful shutdown, the server could exit before
the goroutine completes its DB write or pubsub publish.
### 3. No recovery when watchChats() WebSocket misses events
The frontend relies entirely on the `watchChats()` SSE connection for
title updates. If the connection drops or misses events, titles never
recover — the only fix was a full page reload.
## Fixes
1. **Use `cleanupCtx`** for the `GetChatByID` call and logger in the
deferred cleanup block.
2. **Track the title goroutine** with `p.inflight.Add(1)` / `defer
p.inflight.Done()` so shutdown waits for it.
3. **Invalidate chats query** on WebSocket open/close/error events so
missed updates are recovered via refetch. Also enable
`refetchOnWindowFocus` for the chats query.
Co-authored-by: Coder <coder@users.noreply.github.com>
This commit is contained in:
@@ -1741,10 +1741,10 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
|
||||
// changes made during processing (e.g. AI-generated titles
|
||||
// from maybeGenerateChatTitle). The local `chat` variable
|
||||
// is a value copy and won't reflect updates made in runChat.
|
||||
if freshChat, readErr := p.db.GetChatByID(ctx, chat.ID); readErr == nil {
|
||||
if freshChat, readErr := p.db.GetChatByID(cleanupCtx, chat.ID); readErr == nil {
|
||||
chat = freshChat
|
||||
} else {
|
||||
logger.Warn(ctx, "failed to re-read chat for status event",
|
||||
logger.Warn(cleanupCtx, "failed to re-read chat for status event",
|
||||
slog.F("chat_id", chat.ID), slog.Error(readErr))
|
||||
}
|
||||
chat.Status = status
|
||||
@@ -1816,7 +1816,11 @@ func (p *Server) runChat(
|
||||
// Fire title generation asynchronously so it doesn't block the
|
||||
// chat response. It uses a detached context so it can finish
|
||||
// even after the chat processing context is canceled.
|
||||
go p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
|
||||
p.inflight.Add(1)
|
||||
go func() {
|
||||
defer p.inflight.Done()
|
||||
p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
|
||||
}()
|
||||
|
||||
prompt, err := chatprompt.ConvertMessages(messages)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user