Skip to content

Commit 4e93dcc

Browse files
committed
reliability: route all program.Send through safeSendToProgram
Replaces 44 direct program.Send() calls across app/ package with safeSendToProgram, which adds three layers of protection missing from direct sends: 1. Lock-protected program reference read (prevents racing with shutdown that clears a.program) 2. Nil check (direct sites mixed inline nil guards inconsistently) 3. defer recover() on panic from sending to closed channel Files touched: app.go, app_handlers.go, builder.go, status_callback.go. Before: permission/diff/question/plan-progress/tool-call/tool-result requests could panic or be silently dropped during shutdown races, causing UI hangs. Now all paths converge on the same safe sender. Also: - Clear streamIdleMsg + processingLabel + slowWarningShown on ESC so "X is thinking..." hint doesn't linger after interrupt - Clear streamIdleMsg on StatusCancelled so Ctrl+C produces a clean status line immediately
1 parent 09bb403 commit 4e93dcc

5 files changed

Lines changed: 54 additions & 56 deletions

File tree

internal/app/app.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1693,14 +1693,12 @@ func (a *App) ApplyConfig(cfg *config.Config) error {
16931693
}
16941694

16951695
// 8d. Send ConfigUpdateMsg to Bubbletea program to refresh UI
1696-
if a.program != nil {
1697-
a.program.Send(ui.ConfigUpdateMsg{
1698-
PermissionsEnabled: a.config.Permission.Enabled,
1699-
SandboxEnabled: a.config.Tools.Bash.Sandbox,
1700-
PlanningModeEnabled: a.planningModeEnabled,
1701-
ModelName: a.config.Model.Name,
1702-
})
1703-
}
1696+
a.safeSendToProgram(ui.ConfigUpdateMsg{
1697+
PermissionsEnabled: a.config.Permission.Enabled,
1698+
SandboxEnabled: a.config.Tools.Bash.Sandbox,
1699+
PlanningModeEnabled: a.planningModeEnabled,
1700+
ModelName: a.config.Model.Name,
1701+
})
17041702

17051703
// 9. Update search cache
17061704
if a.config.Cache.Enabled && a.searchCache == nil {
@@ -2004,5 +2002,5 @@ func (a *App) sendAgentTreeUpdate() {
20042002
nodes = append(nodes, node)
20052003
}
20062004

2007-
a.program.Send(ui.AgentTreeUpdateMsg{Nodes: nodes})
2005+
a.safeSendToProgram(ui.AgentTreeUpdateMsg{Nodes: nodes})
20082006
}

internal/app/app_handlers.go

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (a *App) promptPermission(ctx context.Context, req *permission.Request) (pe
2424
}
2525

2626
// Send permission request to TUI
27-
a.program.Send(ui.PermissionRequestMsg{
27+
a.safeSendToProgram(ui.PermissionRequestMsg{
2828
ToolName: req.ToolName,
2929
Args: req.Args,
3030
RiskLevel: req.RiskLevel.String(),
@@ -49,12 +49,10 @@ func (a *App) promptPermission(ctx context.Context, req *permission.Request) (pe
4949
return permission.DecisionDeny, ctx.Err()
5050
case <-warningTimer.C:
5151
// Send warning to UI
52-
if a.program != nil {
53-
a.program.Send(ui.StatusUpdateMsg{
54-
Type: ui.StatusStreamIdle,
55-
Message: fmt.Sprintf("Waiting for permission: %s...", req.ToolName),
56-
})
57-
}
52+
a.safeSendToProgram(ui.StatusUpdateMsg{
53+
Type: ui.StatusStreamIdle,
54+
Message: fmt.Sprintf("Waiting for permission: %s...", req.ToolName),
55+
})
5856
// Reset timer for next reminder
5957
warningTimer.Reset(repeatDelay)
6058
case <-permTimer.C:
@@ -102,7 +100,7 @@ func (a *App) promptQuestion(ctx context.Context, question string, options []str
102100
}
103101

104102
// Send question request to TUI
105-
a.program.Send(ui.QuestionRequestMsg{
103+
a.safeSendToProgram(ui.QuestionRequestMsg{
106104
Question: question,
107105
Options: options,
108106
Default: defaultOpt,
@@ -233,7 +231,7 @@ func (a *App) handlePlanProgressUpdate(progress *plan.ProgressUpdate) {
233231
}
234232

235233
// Send progress update to TUI
236-
a.program.Send(ui.PlanProgressMsg{
234+
a.safeSendToProgram(ui.PlanProgressMsg{
237235
PlanID: progress.PlanID,
238236
CurrentStepID: progress.CurrentStepID,
239237
CurrentTitle: progress.CurrentTitle,
@@ -315,7 +313,7 @@ func (a *App) promptDiffDecision(ctx context.Context, filePath, oldContent, newC
315313
drainDiffDecisionChan(a.diffResponseChan)
316314

317315
// Send diff preview request to TUI
318-
a.program.Send(ui.DiffPreviewRequestMsg{
316+
a.safeSendToProgram(ui.DiffPreviewRequestMsg{
319317
FilePath: filePath,
320318
OldContent: oldContent,
321319
NewContent: newContent,
@@ -381,7 +379,7 @@ func (a *App) promptMultiDiffDecision(ctx context.Context, files []ui.DiffFile)
381379
return decisions, nil
382380
}
383381

384-
a.program.Send(ui.MultiDiffPreviewRequestMsg{Files: files})
382+
a.safeSendToProgram(ui.MultiDiffPreviewRequestMsg{Files: files})
385383

386384
diffTimer := time.NewTimer(DiffDecisionTimeout)
387385
defer diffTimer.Stop()
@@ -494,16 +492,12 @@ func (a *App) handleApplyCodeBlock(filename, content string) {
494492

495493
result, err := writeTool.Execute(ctx, args)
496494
if err != nil {
497-
if a.program != nil {
498-
a.program.Send(ui.ErrorMsg(err))
499-
}
495+
a.safeSendToProgram(ui.ErrorMsg(err))
500496
return
501497
}
502498

503499
if !result.Success {
504-
if a.program != nil {
505-
a.program.Send(ui.ErrorMsg(fmt.Errorf("%s", result.Error)))
506-
}
500+
a.safeSendToProgram(ui.ErrorMsg(fmt.Errorf("%s", result.Error)))
507501
}
508502
}()
509503
}

internal/app/builder.go

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,7 +1387,7 @@ func (b *Builder) wireDependencies() error {
13871387
OnText: func(text string) {
13881388
app.touchStepHeartbeat()
13891389
if app.program != nil {
1390-
app.program.Send(ui.StreamTextMsg(text))
1390+
app.safeSendToProgram(ui.StreamTextMsg(text))
13911391
}
13921392

13931393
// Track streamed text for token estimation
@@ -1401,7 +1401,7 @@ func (b *Builder) wireDependencies() error {
14011401
// Estimate: 1 token ~= 4 chars
14021402
estimatedTokens := chars / 4
14031403
if app.program != nil {
1404-
app.program.Send(ui.StreamTokenUpdateMsg{
1404+
app.safeSendToProgram(ui.StreamTokenUpdateMsg{
14051405
EstimatedOutputTokens: estimatedTokens,
14061406
})
14071407
}
@@ -1411,7 +1411,7 @@ func (b *Builder) wireDependencies() error {
14111411
app.touchStepHeartbeat()
14121412
logging.Debug("OnThinking callback fired", "text_length", len(text), "text_preview", text[:min(len(text), 80)])
14131413
if app.program != nil {
1414-
app.program.Send(ui.StreamThinkingMsg(text))
1414+
app.safeSendToProgram(ui.StreamThinkingMsg(text))
14151415
}
14161416
},
14171417
OnToolStart: func(name string, args map[string]any) {
@@ -1431,7 +1431,7 @@ func (b *Builder) wireDependencies() error {
14311431
}
14321432

14331433
if app.program != nil {
1434-
app.program.Send(ui.ToolCallMsg{Name: name, Args: args})
1434+
app.safeSendToProgram(ui.ToolCallMsg{Name: name, Args: args})
14351435
}
14361436
app.journalEvent("tool_start", map[string]any{
14371437
"tool": name,
@@ -1458,7 +1458,7 @@ func (b *Builder) wireDependencies() error {
14581458
app.mu.Unlock()
14591459

14601460
if app.program != nil {
1461-
app.program.Send(ui.ToolResultMsg{Name: name, Content: result.Content})
1461+
app.safeSendToProgram(ui.ToolResultMsg{Name: name, Content: result.Content})
14621462
}
14631463
app.journalEvent("tool_end", map[string]any{
14641464
"tool": name,
@@ -1475,7 +1475,7 @@ func (b *Builder) wireDependencies() error {
14751475
app.mu.Lock()
14761476
ctx := app.currentToolContext
14771477
app.mu.Unlock()
1478-
app.program.Send(ui.ToolProgressMsg{Name: name, Elapsed: elapsed, CurrentStep: ctx})
1478+
app.safeSendToProgram(ui.ToolProgressMsg{Name: name, Elapsed: elapsed, CurrentStep: ctx})
14791479
}
14801480
},
14811481
OnToolDetailedProgress: func(name string, progress float64, currentStep string) {
@@ -1487,7 +1487,7 @@ func (b *Builder) wireDependencies() error {
14871487
app.mu.Unlock()
14881488
}
14891489
if app.program != nil {
1490-
app.program.Send(ui.ToolProgressMsg{
1490+
app.safeSendToProgram(ui.ToolProgressMsg{
14911491
Name: name,
14921492
Progress: progress,
14931493
CurrentStep: currentStep,
@@ -1499,12 +1499,12 @@ func (b *Builder) wireDependencies() error {
14991499
"error": err.Error(),
15001500
})
15011501
if app.program != nil {
1502-
app.program.Send(ui.ErrorMsg(err))
1502+
app.safeSendToProgram(ui.ErrorMsg(err))
15031503
}
15041504
},
15051505
OnInlineDiff: func(filePath, oldText, newText string) {
15061506
if app.program != nil {
1507-
app.program.Send(ui.InlineDiffMsg{
1507+
app.safeSendToProgram(ui.InlineDiffMsg{
15081508
FilePath: filePath,
15091509
OldText: oldText,
15101510
NewText: newText,
@@ -1513,7 +1513,7 @@ func (b *Builder) wireDependencies() error {
15131513
},
15141514
OnLoopIteration: func(iteration int, toolsUsed int) {
15151515
if app.program != nil {
1516-
app.program.Send(ui.LoopIterationMsg{
1516+
app.safeSendToProgram(ui.LoopIterationMsg{
15171517
Iteration: iteration,
15181518
ToolsUsed: toolsUsed,
15191519
})
@@ -1536,7 +1536,7 @@ func (b *Builder) wireDependencies() error {
15361536
if maxTokens > 0 {
15371537
pct = float64(inputTokens) / float64(maxTokens)
15381538
}
1539-
app.program.Send(ui.TokenUsageMsg{
1539+
app.safeSendToProgram(ui.TokenUsageMsg{
15401540
Tokens: inputTokens,
15411541
MaxTokens: maxTokens,
15421542
PercentUsed: pct,
@@ -1546,7 +1546,7 @@ func (b *Builder) wireDependencies() error {
15461546
},
15471547
OnFilePeek: func(filePath, title, content, action string) {
15481548
if app.program != nil {
1549-
app.program.Send(ui.FilePeekMsg{
1549+
app.safeSendToProgram(ui.FilePeekMsg{
15501550
FilePath: filePath,
15511551
Title: title,
15521552
Content: content,
@@ -1563,7 +1563,7 @@ func (b *Builder) wireDependencies() error {
15631563
}
15641564
msg += ": " + summary
15651565
}
1566-
app.program.Send(ui.LearningInsightMsg{Message: msg})
1566+
app.safeSendToProgram(ui.LearningInsightMsg{Message: msg})
15671567
}
15681568
},
15691569
})
@@ -1647,7 +1647,7 @@ func (b *Builder) wireDependencies() error {
16471647
if len(desc) > 50 {
16481648
desc = desc[:47] + "..."
16491649
}
1650-
app.program.Send(ui.BackgroundTaskMsg{
1650+
app.safeSendToProgram(ui.BackgroundTaskMsg{
16511651
ID: id,
16521652
Type: "agent",
16531653
Description: desc,
@@ -1666,7 +1666,7 @@ func (b *Builder) wireDependencies() error {
16661666
status = "cancelled"
16671667
}
16681668
}
1669-
app.program.Send(ui.BackgroundTaskMsg{
1669+
app.safeSendToProgram(ui.BackgroundTaskMsg{
16701670
ID: id,
16711671
Type: "agent",
16721672
Status: status,
@@ -1710,7 +1710,7 @@ func (b *Builder) wireDependencies() error {
17101710
if total < 1 {
17111711
total = 1
17121712
}
1713-
app.program.Send(ui.BackgroundTaskProgressMsg{
1713+
app.safeSendToProgram(ui.BackgroundTaskProgressMsg{
17141714
ID: id,
17151715
Progress: float64(progress.CurrentStep) / float64(total),
17161716
CurrentStep: progress.CurrentStep,
@@ -1732,21 +1732,21 @@ func (b *Builder) wireDependencies() error {
17321732
}
17331733

17341734
if app.program != nil {
1735-
app.program.Send(ui.ScratchpadMsg(content))
1735+
app.safeSendToProgram(ui.ScratchpadMsg(content))
17361736
}
17371737
})
17381738

17391739
// Wire thinking callback for sub-agents
17401740
b.agentRunner.SetOnThinking(func(text string) {
17411741
if app.program != nil {
1742-
app.program.Send(ui.StreamThinkingMsg(text))
1742+
app.safeSendToProgram(ui.StreamThinkingMsg(text))
17431743
}
17441744
})
17451745

17461746
// Wire sub-agent activity to UI
17471747
b.agentRunner.SetOnSubAgentActivity(func(agentID, agentType, toolName string, args map[string]any, status string) {
17481748
if app.program != nil {
1749-
app.program.Send(ui.SubAgentActivityMsg{
1749+
app.safeSendToProgram(ui.SubAgentActivityMsg{
17501750
AgentID: agentID,
17511751
AgentType: agentType,
17521752
ToolName: toolName,
@@ -1846,7 +1846,7 @@ func (b *Builder) wireDependencies() error {
18461846
b.metaAgent.SetInterventionCallback(func(agentID string, reason string, action string) {
18471847
if app.program != nil {
18481848
msg := fmt.Sprintf("⚡ Meta-Agent: %s → %s", action, reason)
1849-
app.program.Send(ui.LearningInsightMsg{Message: msg})
1849+
app.safeSendToProgram(ui.LearningInsightMsg{Message: msg})
18501850
}
18511851
})
18521852
}
@@ -1855,7 +1855,7 @@ func (b *Builder) wireDependencies() error {
18551855
if b.agentRunner != nil {
18561856
b.agentRunner.SetOnThinking(func(text string) {
18571857
if app.program != nil {
1858-
app.program.Send(ui.StreamThinkingMsg(text))
1858+
app.safeSendToProgram(ui.StreamThinkingMsg(text))
18591859
}
18601860
})
18611861
}
@@ -1982,7 +1982,7 @@ type uiBroadcasterAdapter struct {
19821982
// BroadcastTaskStarted sends a task started event to the UI.
19831983
func (a *uiBroadcasterAdapter) BroadcastTaskStarted(taskID, message, planType string) {
19841984
if a.app != nil && a.app.program != nil {
1985-
a.app.program.Send(ui.TaskStartedEvent{
1985+
a.app.safeSendToProgram(ui.TaskStartedEvent{
19861986
TaskID: taskID,
19871987
Message: message,
19881988
PlanType: planType,
@@ -1993,7 +1993,7 @@ func (a *uiBroadcasterAdapter) BroadcastTaskStarted(taskID, message, planType st
19931993
// BroadcastTaskCompleted sends a task completed event to the UI.
19941994
func (a *uiBroadcasterAdapter) BroadcastTaskCompleted(taskID string, success bool, duration time.Duration, err error, planType string) {
19951995
if a.app != nil && a.app.program != nil {
1996-
a.app.program.Send(ui.TaskCompletedEvent{
1996+
a.app.safeSendToProgram(ui.TaskCompletedEvent{
19971997
TaskID: taskID,
19981998
Success: success,
19991999
Duration: duration,
@@ -2006,7 +2006,7 @@ func (a *uiBroadcasterAdapter) BroadcastTaskCompleted(taskID string, success boo
20062006
// BroadcastTaskProgress sends a task progress event to the UI.
20072007
func (a *uiBroadcasterAdapter) BroadcastTaskProgress(taskID string, progress float64, message string) {
20082008
if a.app != nil && a.app.program != nil {
2009-
a.app.program.Send(ui.TaskProgressEvent{
2009+
a.app.safeSendToProgram(ui.TaskProgressEvent{
20102010
TaskID: taskID,
20112011
Progress: progress,
20122012
Message: message,

internal/app/status_callback.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ func (c *appStatusCallback) OnRetry(attempt, maxAttempts int, delay time.Duratio
2222
msg := fmt.Sprintf("Retry %d/%d in %s (%s)",
2323
attempt, maxAttempts, delay.Round(time.Second), reason)
2424

25-
c.app.program.Send(ui.StatusUpdateMsg{
25+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
2626
Type: ui.StatusRetry,
2727
Message: msg,
2828
Details: map[string]any{
@@ -42,7 +42,7 @@ func (c *appStatusCallback) OnRateLimit(waitTime time.Duration) {
4242

4343
msg := fmt.Sprintf("Rate limit, waiting %s...", waitTime.Round(time.Second))
4444

45-
c.app.program.Send(ui.StatusUpdateMsg{
45+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
4646
Type: ui.StatusRateLimit,
4747
Message: msg,
4848
Details: map[string]any{
@@ -62,7 +62,7 @@ func (c *appStatusCallback) OnStreamIdle(elapsed time.Duration) {
6262
msg = fmt.Sprintf("Waiting for response %s... (ESC to cancel)", elapsed.Round(time.Second))
6363
}
6464

65-
c.app.program.Send(ui.StatusUpdateMsg{
65+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
6666
Type: ui.StatusStreamIdle,
6767
Message: msg,
6868
Details: map[string]any{
@@ -82,7 +82,7 @@ func (c *appStatusCallback) OnThinkingIdle(elapsed time.Duration, provider strin
8282
msg = fmt.Sprintf("%s is thinking %s... (ESC to cancel)", provider, elapsed.Round(time.Second))
8383
}
8484

85-
c.app.program.Send(ui.StatusUpdateMsg{
85+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
8686
Type: ui.StatusThinkingIdle,
8787
Message: msg,
8888
Details: map[string]any{
@@ -99,7 +99,7 @@ func (c *appStatusCallback) OnStreamResume() {
9999
}
100100

101101
// Send resume message - this can be used to clear any warning toasts
102-
c.app.program.Send(ui.StatusUpdateMsg{
102+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
103103
Type: ui.StatusStreamResume,
104104
Message: "",
105105
})
@@ -117,7 +117,7 @@ func (c *appStatusCallback) OnError(err error, recoverable bool) {
117117
}
118118
ft := client.DetectFailureTelemetry(err)
119119

120-
c.app.program.Send(ui.StatusUpdateMsg{
120+
c.app.safeSendToProgram(ui.StatusUpdateMsg{
121121
Type: ui.StatusRecoverableError,
122122
Message: msg,
123123
Details: map[string]any{

internal/ui/tui.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,8 +1228,11 @@ func (m *Model) handleGlobalKeys(msg tea.KeyMsg) tea.Cmd {
12281228
m.state = StateInput
12291229
m.currentTool = ""
12301230
m.currentToolInfo = ""
1231+
m.processingLabel = ""
1232+
m.streamIdleMsg = "" // Clear any "X is thinking" / "waiting for response" hint
12311233
m.activeToolCalls = nil
12321234
m.streamStartTime = time.Time{} // Reset timeout tracking
1235+
m.slowWarningShown = false
12331236
// Hide tool progress bar if visible
12341237
if m.toolProgressBar != nil {
12351238
m.toolProgressBar.Hide()
@@ -2050,6 +2053,9 @@ func (m *Model) handleMessageTypes(msg tea.Msg) tea.Cmd {
20502053
m.toastManager.ShowError(msg.Message)
20512054
}
20522055
case StatusCancelled:
2056+
// Cancellation: clear any in-flight idle/thinking hints so the user
2057+
// sees a clean state while the operation winds down.
2058+
m.streamIdleMsg = ""
20532059
if m.toastManager != nil {
20542060
m.toastManager.ShowWarning(msg.Message)
20552061
}

0 commit comments

Comments
 (0)