Repository: charmbracelet/bubbletea (Go)

Issue:
Deadlock on context cancellation (inside eventLoop)
**Describe the bug**
A context cancellation while the program is inside the message processing part of the _eventLoop_ deadlocks. It is trying to send a command into the _cmds_ channel, which no longer has any receivers. This happens because at that point the _handleCommands_ has already exited, having detected the cancelled context, and the _eventLoop_ blocks indefinitely while trying to send into the stale commands channel.

May happen here:
https://github.com/charmbracelet/bubbletea/blob/6a1ebaa0ea00f482b7e1b9d3c47c3eb978e70fe9/tea.go#L463

Definitely happens here:
https://github.com/charmbracelet/bubbletea/blob/6a1ebaa0ea00f482b7e1b9d3c47c3eb978e70fe9/tea.go#L509

**To Reproduce**
Cancellation of a given _tea.WithContext(ctx)_ inside a high message pressure implementation, e.g. with 1-second interval Tick commands.

**Expected behavior**
Program exit with appropriate and timely teardown on context cancellation.

**Possible Hotfix**
**Wrapping the command channel sends with appropriate select statements resolves the issue**. However, I'm not sure if generally more safeguards may need to be put in place in the event loop's message processing part. Some dependent routines may already be shutdown due to context cancellation and message handling inside the event loop may still call such functions if context cancellation isn't handled before that happens.

```
select {
case <-p.ctx.Done():
	return model, nil
case cmds <- cmd:
}
```

**Additional context (Stacktrace)**
```
goroutine 42 [chan send, 4 minutes]:
github.com/charmbracelet/bubbletea.(*Program).eventLoop(0xc000264140, {0x673950?, 0xc0003b8008?}, 0xc0001ae230)
        github.com/charmbracelet/bubbletea@v1.3.4/tea.go:509 +0x7f7
github.com/charmbracelet/bubbletea.(*Program).Run(0xc000264140)
        github.com/charmbracelet/bubbletea@v1.3.4/tea.go:647 +0xabc
```



Discussion:

Patch:
diff --git a/tea.go b/tea.go
index 0bc915e5b3..310530a25c 100644
--- a/tea.go
+++ b/tea.go
@@ -460,7 +460,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 
 			case BatchMsg:
 				for _, cmd := range msg {
-					cmds <- cmd
+					select {
+					case <-p.ctx.Done():
+						return model, nil
+					case cmds <- cmd:
+					}
 				}
 				continue
 
@@ -506,7 +510,13 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
 
 			var cmd Cmd
 			model, cmd = model.Update(msg) // run update
-			cmds <- cmd                    // process command (if any)
+
+			select {
+			case <-p.ctx.Done():
+				return model, nil
+			case cmds <- cmd: // process command (if any)
+			}
+
 			p.renderer.write(model.View()) // send view to renderer
 		}
 	}


Test patch:
diff --git a/tea_test.go b/tea_test.go
index 981851be0b..9e4c55e7f8 100644
--- a/tea_test.go
+++ b/tea_test.go
@@ -9,6 +9,10 @@ import (
 	"time"
 )
 
+type ctxImplodeMsg struct {
+	cancel context.CancelFunc
+}
+
 type incrementMsg struct{}
 
 type testModel struct {
@@ -21,7 +25,11 @@ func (m testModel) Init() Cmd {
 }
 
 func (m *testModel) Update(msg Msg) (Model, Cmd) {
-	switch msg.(type) {
+	switch msg := msg.(type) {
+	case ctxImplodeMsg:
+		msg.cancel()
+		time.Sleep(100 * time.Millisecond)
+
 	case incrementMsg:
 		i := m.counter.Load()
 		if i == nil {
@@ -165,6 +173,59 @@ func TestTeaContext(t *testing.T) {
 	}
 }
 
+func TestTeaContextImplodeDeadlock(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	m := &testModel{}
+	p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				p.Send(ctxImplodeMsg{cancel: cancel})
+				return
+			}
+		}
+	}()
+
+	if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
+func TestTeaContextBatchDeadlock(t *testing.T) {
+	ctx, cancel := context.WithCancel(context.Background())
+	var buf bytes.Buffer
+	var in bytes.Buffer
+
+	inc := func() Msg {
+		cancel()
+		return incrementMsg{}
+	}
+
+	m := &testModel{}
+	p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf))
+	go func() {
+		for {
+			time.Sleep(time.Millisecond)
+			if m.executed.Load() != nil {
+				batch := make(BatchMsg, 100)
+				for i := range batch {
+					batch[i] = inc
+				}
+				p.Send(batch)
+				return
+			}
+		}
+	}()
+
+	if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) {
+		t.Fatalf("Expected %v, got %v", ErrProgramKilled, err)
+	}
+}
+
 func TestTeaBatchMsg(t *testing.T) {
 	var buf bytes.Buffer
 	var in bytes.Buffer