Skip to content

Commit d8842ce

Browse files
committed
fix(terminal): handle refresh shutdown safely
1 parent 73c77c5 commit d8842ce

2 files changed

Lines changed: 86 additions & 28 deletions

File tree

internal/terminal/refresh.go

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,22 @@ type terminalRefreshResult struct {
4040

4141
type terminalRefreshLoader func(context.Context, *terminalRefreshRequest) terminalRefreshSnapshot
4242

43-
func (app *App) requestTerminalRefresh(ctx context.Context) {
43+
func (app *App) requestTerminalRefresh(ctx context.Context) <-chan struct{} {
4444
// Keep the parent task/workflow summary stable while inspecting a child.
4545
if len(app.agentTaskSessionStack) > 0 {
46-
return
46+
return nil
4747
}
4848

4949
if app.refreshInFlight {
5050
app.refreshPending = true
5151

5252
app.logTerminalRefresh("coalesce", 0, "pending")
5353

54-
return
54+
return nil
5555
}
5656

5757
if app.runtime == nil || app.sessionID == "" {
58-
return
58+
return nil
5959
}
6060

6161
app.refreshGeneration++
@@ -73,8 +73,11 @@ func (app *App) requestTerminalRefresh(ctx context.Context) {
7373

7474
screen := app.screen
7575
diagnostics := app.refreshDiagnostics
76+
done := make(chan struct{})
7677

7778
go func() {
79+
defer close(done)
80+
7881
refreshCtx, cancel := context.WithTimeout(loadCtx, terminalRefreshTimeout)
7982
defer cancel()
8083

@@ -93,6 +96,8 @@ func (app *App) requestTerminalRefresh(ctx context.Context) {
9396
logTerminalRefreshTimings(diagnostics, result, outcome)
9497
postTerminalRefreshResult(ctx, screen, result)
9598
}()
99+
100+
return done
96101
}
97102

98103
func refreshOutcome(result *terminalRefreshResult) string {
@@ -133,10 +138,37 @@ func postTerminalRefreshResult(ctx context.Context, screen terminalScreen, resul
133138
return
134139
}
135140

141+
stop := screenStop(screen)
142+
select {
143+
case <-stop:
144+
return
145+
default:
146+
}
147+
148+
postTerminalRefreshEvent(ctx, screen.EventQ(), stop, tcell.NewEventInterrupt(result))
149+
}
150+
151+
func postTerminalRefreshEvent(
152+
ctx context.Context,
153+
events chan<- tcell.Event,
154+
stop <-chan struct{},
155+
event tcell.Event,
156+
) (posted bool) {
157+
// Test screens may close their event queue during shutdown. Treat that race
158+
// like a stopped screen instead of crashing the refresh worker.
159+
defer func() {
160+
if recover() != nil {
161+
posted = false
162+
}
163+
}()
164+
136165
select {
137-
case screen.EventQ() <- tcell.NewEventInterrupt(result):
166+
case events <- event:
167+
return true
138168
case <-ctx.Done():
139-
case <-screenStop(screen):
169+
return false
170+
case <-stop:
171+
return false
140172
}
141173
}
142174

@@ -290,17 +322,10 @@ func (diagnostics *terminalRefreshDiagnostics) log(
290322
outcome string,
291323
count int,
292324
) {
293-
attributes := []any{
294-
slog.String("operation", operation),
295-
slog.Duration("duration", duration),
296-
slog.String("outcome", outcome),
297-
}
298-
if count > 0 {
299-
attributes = append(attributes, slog.Int("count", count))
300-
}
301-
302325
logger := slog.Default()
303-
logger.Debug("terminal refresh", attributes...)
326+
if logger.Enabled(context.Background(), slog.LevelDebug) {
327+
logger.Debug("terminal refresh", terminalRefreshAttributes(operation, duration, outcome, count)...)
328+
}
304329

305330
if duration < terminalSlowOperationThreshold {
306331
return
@@ -324,6 +349,20 @@ func (diagnostics *terminalRefreshDiagnostics) log(
324349
diagnostics.suppressed[key] = 0
325350
diagnostics.Unlock()
326351

352+
attributes := terminalRefreshAttributes(operation, duration, outcome, count)
327353
attributes = append(attributes, slog.Int("suppressed_count", suppressed))
328354
logger.Warn("terminal refresh slow", attributes...)
329355
}
356+
357+
func terminalRefreshAttributes(operation string, duration time.Duration, outcome string, count int) []any {
358+
attributes := []any{
359+
slog.String("operation", operation),
360+
slog.Duration("duration", duration),
361+
slog.String("outcome", outcome),
362+
}
363+
if count > 0 {
364+
attributes = append(attributes, slog.Int("count", count))
365+
}
366+
367+
return attributes
368+
}

internal/terminal/refresh_acceptance_internal_test.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,12 @@ func TestTerminalRefreshCancellationWhileRuntimeLoadIsBlocked(t *testing.T) {
207207
})
208208

209209
ctx, cancel := context.WithCancel(context.Background())
210-
app.requestTerminalRefresh(ctx)
210+
done := app.requestTerminalRefresh(ctx)
211+
require.NotNil(t, done)
211212
awaitSignal(t, started, "refresh did not enter Runtime.AgentTasks")
212213
cancel()
213214
awaitSignal(t, stub.exited, "Runtime.AgentTasks did not return after application cancellation")
215+
awaitSignal(t, done, "refresh worker did not finish after application cancellation")
214216

215217
select {
216218
case event := <-screen.EventQ():
@@ -249,25 +251,42 @@ func TestTerminalRefreshPublicationStopsWithScreen(t *testing.T) {
249251
t.Parallel()
250252

251253
app, screen := newRefreshTestApp(t)
252-
for index := 0; index < cap(screen.EventQ()); index++ {
253-
screen.EventQ() <- tcell.NewEventInterrupt(index)
254+
close(screen.stop)
255+
256+
postTerminalRefreshResult(t.Context(), app.screen, &terminalRefreshResult{
257+
Snapshot: newTerminalRefreshSnapshot(""), SessionID: "", Timing: terminalRefreshTiming{
258+
Total: 0, AgentTasks: 0, AgentPanel: 0, ToolTasks: 0,
259+
Workflows: 0, Details: 0, WorkflowPanel: 0,
260+
},
261+
Generation: 0, TimedOut: false, Canceled: false,
262+
})
263+
264+
select {
265+
case event := <-screen.EventQ():
266+
t.Fatalf("stopped screen received refresh event %T", event)
267+
default:
254268
}
269+
}
255270

256-
done := make(chan struct{})
271+
func TestTerminalRefreshPublicationHandlesClosedEventQueue(t *testing.T) {
272+
t.Parallel()
257273

258-
go func() {
259-
postTerminalRefreshResult(t.Context(), app.screen, &terminalRefreshResult{
274+
events := make(chan tcell.Event)
275+
close(events)
276+
277+
posted := postTerminalRefreshEvent(
278+
t.Context(),
279+
events,
280+
nil,
281+
tcell.NewEventInterrupt(&terminalRefreshResult{
260282
Snapshot: newTerminalRefreshSnapshot(""), SessionID: "", Timing: terminalRefreshTiming{
261283
Total: 0, AgentTasks: 0, AgentPanel: 0, ToolTasks: 0,
262284
Workflows: 0, Details: 0, WorkflowPanel: 0,
263285
},
264286
Generation: 0, TimedOut: false, Canceled: false,
265-
})
266-
close(done)
267-
}()
268-
269-
close(screen.stop)
270-
awaitSignal(t, done, "blocked refresh publication did not stop with the screen")
287+
}),
288+
)
289+
assert.False(t, posted)
271290
}
272291

273292
func TestTerminalRefreshLatestStateCoalescing(t *testing.T) {

0 commit comments

Comments
 (0)