fix(ws): detach dispatched handler ctx from WS connection lifetime (#959)

This commit is contained in:
Carlos Florêncio
2026-05-19 21:43:45 +01:00
committed by GitHub
parent 9fd789ba04
commit 8467dd94f8
5 changed files with 336 additions and 9 deletions
@@ -60,8 +60,14 @@ func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *C
}
}
// ReadPump pumps messages from the WebSocket connection to the hub
func (c *Client) ReadPump(ctx context.Context) {
// ReadPump pumps messages from the WebSocket connection to the hub.
//
// The ctx argument is retained for API stability but is not consulted by the
// pump itself — Gorilla's ReadMessage blocks on the conn only, so teardown
// happens via the conn close path (driven by client disconnect, server
// shutdown closing all conns, or pong timeout). Dispatched handlers use the
// hub's lifetime context instead; see handleMessage.
func (c *Client) ReadPump(_ context.Context) {
defer func() {
c.hub.Unregister(c)
if err := c.conn.Close(); err != nil {
@@ -100,12 +106,16 @@ func (c *Client) ReadPump(ctx context.Context) {
// Process the message in a goroutine to avoid blocking the read pump
// This allows concurrent message handling so long-running handlers
// (like orchestrator.prompt) don't block other requests (like workspace.tree.get)
go c.handleMessage(ctx, &msg)
go c.handleMessage(&msg)
}
}
// handleMessage processes an incoming message
func (c *Client) handleMessage(ctx context.Context, msg *ws.Message) {
// handleMessage processes an incoming message.
//
// Intentionally does NOT take the connection context. Dispatched handlers run
// under the hub's lifetime context so a mid-flight client disconnect doesn't
// abort in-progress side effects (see the comment on Dispatch below).
func (c *Client) handleMessage(msg *ws.Message) {
c.logger.Debug("Received message",
zap.String("action", msg.Action),
zap.String("id", msg.ID))
@@ -144,8 +154,17 @@ func (c *Client) handleMessage(ctx context.Context, msg *ws.Message) {
return
}
// Dispatch to handler
response, err := c.hub.dispatcher.Dispatch(ctx, msg)
// Dispatch to handler using the hub's lifetime context, not the per-
// connection one. The connection ctx is cancelled when the client
// disconnects (page reload, nav, network drop). Using it here would
// SIGKILL any exec.CommandContext subprocesses the handler spawned
// (e.g. `gh pr`, `git`, agentctl HTTP requests) and abort
// side-effecting work like session.launch mid-flight. We can't deliver
// the response either way once the connection is gone, but the
// handler's work should run to completion so it doesn't leave partial
// state behind. The dispatch ctx still cancels on server shutdown.
dispatchCtx := c.hub.DispatchContext()
response, err := c.hub.dispatcher.Dispatch(dispatchCtx, msg)
if err != nil {
c.logger.Error("Handler error",
zap.String("action", msg.Action),
@@ -0,0 +1,191 @@
package websocket
import (
"context"
"errors"
"testing"
"time"
ws "github.com/kandev/kandev/pkg/websocket"
)
// TestHub_DispatchContextFallsBackBeforeRun guards the test-setup path:
// DispatchContext must never return a nil context, even when Run was never
// started (e.g. tests that just exercise handlers directly).
func TestHub_DispatchContextFallsBackBeforeRun(t *testing.T) {
h := newTestHub(t)
got := h.DispatchContext()
if got == nil {
t.Fatal("DispatchContext returned nil before Run; handlers would NPE")
}
if err := got.Err(); err != nil {
t.Fatalf("fallback ctx should not be done, got err=%v", err)
}
}
// TestHub_DispatchContextTracksRunCtx checks that DispatchContext returns the
// ctx Run was called with, so it cancels on server shutdown (the only
// cancellation reason that should still kill dispatched handlers).
func TestHub_DispatchContextTracksRunCtx(t *testing.T) {
h := newTestHub(t)
runCtx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set directly via in-package field access — mirrors what Run does without
// the goroutine scheduling race.
h.mu.Lock()
h.dispatchCtx = runCtx
h.mu.Unlock()
dispatchCtx := h.DispatchContext()
if dispatchCtx.Err() != nil {
t.Fatalf("dispatchCtx prematurely done: %v", dispatchCtx.Err())
}
cancel()
// Cancellation is synchronous once the parent ctx is cancelled.
if dispatchCtx.Err() == nil {
t.Fatal("dispatchCtx did not cancel when hub ctx was cancelled")
}
}
// TestClient_HandleMessageUsesHubCtxNotConnCtx is the regression test for the
// SIGKILL cascade: a dispatched handler must NOT see a cancelled context when
// the originating client disconnects. Before the fix, the dispatcher was
// called with the connection ctx, so any in-flight exec.CommandContext
// subprocess (gh, git, agentctl HTTP) got SIGKILL'd the moment the user
// navigated away from the page.
func TestClient_HandleMessageUsesHubCtxNotConnCtx(t *testing.T) {
h := newTestHub(t)
dispatcher := ws.NewDispatcher()
h.dispatcher = dispatcher
hubCtx, hubCancel := context.WithCancel(context.Background())
defer hubCancel()
// Set directly before Run so the goroutine has no scheduling race.
h.mu.Lock()
h.dispatchCtx = hubCtx
h.mu.Unlock()
go h.Run(hubCtx)
// Handler that records what ctx it received and waits long enough for
// the test to cancel a fake connection ctx before returning. If the
// dispatcher were still wired to the connection ctx, gotCtx.Err()
// below would be non-nil.
handlerEntered := make(chan struct{})
releaseHandler := make(chan struct{})
var gotCtx context.Context
dispatcher.RegisterFunc("test.write", func(ctx context.Context, _ *ws.Message) (*ws.Message, error) {
gotCtx = ctx
close(handlerEntered)
<-releaseHandler
return nil, nil
})
c := newTestClient("c-disconnect")
c.hub = h
// Run handleMessage in a goroutine so we can cancel the "connection"
// while the handler is mid-flight.
done := make(chan struct{})
go func() {
defer close(done)
c.handleMessage(&ws.Message{Action: "test.write", Type: ws.MessageTypeRequest})
}()
select {
case <-handlerEntered:
case <-time.After(time.Second):
t.Fatal("handler never entered; dispatch wiring is broken")
}
// The handler is inside Dispatch. In production this is the moment the
// WS client disconnects. Verify the handler's ctx is not derived from
// any connection lifetime: we don't even have a connection ctx to
// cancel here, which is exactly the point — handleMessage no longer
// takes one.
if gotCtx == nil {
t.Fatal("handler did not receive a context")
}
if err := gotCtx.Err(); err != nil {
t.Fatalf("handler ctx already done at entry: %v", err)
}
// Sanity: the handler ctx must be the hub's lifetime ctx (or derived
// from it). Cancel the hub and observe. Cancellation is synchronous
// because gotCtx IS hubCtx (DispatchContext returns the field directly).
hubCancel()
if !errors.Is(gotCtx.Err(), context.Canceled) {
t.Fatalf("handler ctx should cancel with hub shutdown, got err=%v", gotCtx.Err())
}
close(releaseHandler)
<-done
}
// TestClient_HandleMessageSurvivesConnectionTeardown is the higher-level
// regression: even though ReadPump may exit and the client may be torn down,
// already-dispatched handlers continue running. Models the real bug — a
// session.launch already inside the handler when the WS closes.
func TestClient_HandleMessageSurvivesConnectionTeardown(t *testing.T) {
h := newTestHub(t)
dispatcher := ws.NewDispatcher()
h.dispatcher = dispatcher
hubCtx, hubCancel := context.WithCancel(context.Background())
defer hubCancel()
h.mu.Lock()
h.dispatchCtx = hubCtx
h.mu.Unlock()
go h.Run(hubCtx)
handlerEntered := make(chan struct{})
handlerCompleted := make(chan error, 1)
releaseHandler := make(chan struct{})
dispatcher.RegisterFunc("session.launch.fake", func(ctx context.Context, _ *ws.Message) (*ws.Message, error) {
close(handlerEntered)
// Mimic an exec.CommandContext-style subroutine that watches ctx.
select {
case <-releaseHandler:
handlerCompleted <- ctx.Err()
case <-ctx.Done():
handlerCompleted <- ctx.Err()
}
return nil, nil
})
c := newTestClient("c-teardown")
c.hub = h
go c.handleMessage(&ws.Message{Action: "session.launch.fake", Type: ws.MessageTypeRequest})
select {
case <-handlerEntered:
case <-time.After(time.Second):
t.Fatal("handler never entered; dispatch wiring is broken")
}
// Simulate connection teardown: close the client's send channel and
// drop our hub reference, the way removeClient would. Crucially, this
// does NOT cancel the hub ctx — only client-scoped state goes away.
c.mu.Lock()
c.closed = true
c.mu.Unlock()
// Handler must still be running. Release it explicitly.
close(releaseHandler)
select {
case err := <-handlerCompleted:
if err != nil {
t.Fatalf("handler ctx was cancelled by connection teardown; want nil, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("handler never completed; deadlock or wrong ctx wiring")
}
}
@@ -45,6 +45,13 @@ type Hub struct {
// effective mode (paused/slow/fast) transitions. See hub_session_mode.go.
sessionMode *sessionModeTracker
// dispatchCtx is the hub's lifetime context, set by Run. Dispatched
// message handlers use it instead of the per-connection context so that
// a client disconnecting mid-flight does not SIGKILL exec subprocesses
// (gh, git, agentctl HTTP calls) or otherwise abort side-effecting work
// like session.launch. It still cancels on server shutdown.
dispatchCtx context.Context
mu sync.RWMutex
logger *logger.Logger
}
@@ -71,6 +78,10 @@ func (h *Hub) Run(ctx context.Context) {
h.logger.Info("WebSocket hub started")
defer h.logger.Info("WebSocket hub stopped")
h.mu.Lock()
h.dispatchCtx = ctx
h.mu.Unlock()
for {
select {
case <-ctx.Done():
@@ -437,6 +448,20 @@ func (h *Hub) GetDispatcher() *ws.Dispatcher {
return h.dispatcher
}
// DispatchContext returns a context whose lifetime is tied to the hub (and
// therefore the server) rather than any single client connection. Dispatched
// handlers should use this so that a client disconnecting mid-flight does not
// cancel in-progress writes, exec subprocesses, or downstream HTTP calls.
// Falls back to context.Background when Run has not been called (test setups).
func (h *Hub) DispatchContext() context.Context {
h.mu.RLock()
defer h.mu.RUnlock()
if h.dispatchCtx == nil {
return context.Background()
}
return h.dispatchCtx
}
// SetSessionDataProvider sets the provider for session data on subscription
func (h *Hub) SetSessionDataProvider(provider SessionDataProvider) {
h.sessionDataProvider = provider
@@ -883,8 +883,13 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin
if task, taskErr := s.repo.GetTask(resumeCtx, taskID); taskErr == nil && task != nil && task.ArchivedAt != nil {
return nil, executor.ErrTaskArchived
}
s.updateTaskSessionState(ctx, taskID, sessionID, models.TaskSessionStateFailed, err.Error(), false, session)
if stateErr := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateFailed); stateErr != nil {
// Use resumeCtx (WithoutCancel) for the failure-recording writes too —
// if the caller's ctx was already cancelled (e.g. WS client navigated
// away), the SessionStateFailed and TaskStateFailed updates would
// themselves fail with "context canceled" and leave the task stuck
// looking "running" forever.
s.updateTaskSessionState(resumeCtx, taskID, sessionID, models.TaskSessionStateFailed, err.Error(), false, session)
if stateErr := s.taskRepo.UpdateTaskState(resumeCtx, taskID, v1.TaskStateFailed); stateErr != nil {
s.logger.Warn("failed to update task state to FAILED after resume error",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
@@ -878,6 +878,93 @@ func TestResumeTaskSession_FailedKeepsResumeToken(t *testing.T) {
}
}
// ctxAwareTaskRepo wraps mockTaskRepo and respects ctx cancellation. Used to
// prove that ResumeTaskSession's failure-recording path is insulated from a
// pre-cancelled caller ctx (the WS-disconnect scenario).
type ctxAwareTaskRepo struct {
inner *mockTaskRepo
}
func (c *ctxAwareTaskRepo) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return c.inner.GetTask(ctx, taskID)
}
func (c *ctxAwareTaskRepo) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
if err := ctx.Err(); err != nil {
return err
}
return c.inner.UpdateTaskState(ctx, taskID, state)
}
// TestResumeTaskSession_FailedStateWriteSurvivesCancelledCallerCtx verifies the
// fix for the WS-disconnect cascade: when the caller's ctx was already
// cancelled (e.g. the user navigated away mid-resume) and the launch then
// failed, the FAILED state-update writes must still go through using the
// detached resumeCtx — otherwise the task is stuck looking "running" forever.
//
// Before the fix, lines 886-892 used the original ctx, so the failure-state
// write itself returned "context canceled" and the WARN "failed to update task
// state to FAILED after resume error: context canceled" appeared in the logs.
func TestResumeTaskSession_FailedStateWriteSurvivesCancelledCallerCtx(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repo := setupTestRepo(t)
mockTR := newMockTaskRepo()
taskRepo := &ctxAwareTaskRepo{inner: mockTR}
// Cancel the caller ctx the moment launch is invoked. This mirrors the
// WS-disconnect race: the request handler's ctx is alive when the
// resume path starts (so it gets through the early-exit checks against
// sqlite/etc.) and dies mid-launch. The post-launch failure-recording
// writes use resumeCtx (WithoutCancel) and must still succeed.
agentMgr := &mockAgentManager{
launchAgentFunc: func(_ context.Context, _ *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
cancel()
return nil, errors.New("simulated launch failure")
},
}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), mockTR, agentMgr)
svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{})
// Override with the ctx-aware wrapper so we can detect ctx-canceled
// writes — the bare mockTaskRepo ignores ctx entirely.
svc.taskRepo = taskRepo
seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput)
session, _ := repo.GetTaskSession(ctx, "session1")
session.AgentProfileID = "profile-1"
_ = repo.UpdateTaskSession(ctx, session)
now := time.Now().UTC()
_ = repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{
ID: "er1", SessionID: "session1", TaskID: "task1",
CreatedAt: now, UpdatedAt: now,
})
_, err := svc.ResumeTaskSession(ctx, "task1", "session1")
if err == nil {
t.Fatal("expected ResumeTaskSession to return an error from the simulated launch failure")
}
state, ok := mockTR.updatedStates["task1"]
if !ok {
t.Fatal("task FAILED state was NOT persisted; the failure-recording write was cancelled by the caller ctx")
}
if state != v1.TaskStateFailed {
t.Errorf("expected task1 state=FAILED, got %v", state)
}
persisted, getErr := repo.GetTaskSession(context.Background(), "session1")
if getErr != nil {
t.Fatalf("failed to reload session: %v", getErr)
}
if persisted.State != models.TaskSessionStateFailed {
t.Errorf("expected session1 state=FAILED, got %v", persisted.State)
}
}
// --- CompleteTask ---
func TestCompleteTask_UpdatesTaskState(t *testing.T) {