Skip to content

Commit cdc9a23

Browse files
authored
chore: merge pull request #1388 from desertwitch/stacked-patches-v2
(v2) tea.go: deadlock/error return fixes, additional tests (targeted at v2)
2 parents 7858a14 + 9404e06 commit cdc9a23

File tree

4 files changed

+327
-25
lines changed

4 files changed

+327
-25
lines changed

options.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type ProgramOption func(*Program)
2121
// cancelled it will exit with an error ErrProgramKilled.
2222
func WithContext(ctx context.Context) ProgramOption {
2323
return func(p *Program) {
24-
p.ctx = ctx
24+
p.externalCtx = ctx
2525
}
2626
}
2727

options_test.go

+11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tea
22

33
import (
44
"bytes"
5+
"context"
56
"os"
67
"sync/atomic"
78
"testing"
@@ -41,6 +42,16 @@ func TestOptions(t *testing.T) {
4142
}
4243
})
4344

45+
t.Run("external context", func(t *testing.T) {
46+
extCtx, extCancel := context.WithCancel(context.Background())
47+
defer extCancel()
48+
49+
p := NewProgram(nil, WithContext(extCtx))
50+
if p.externalCtx != extCtx || p.externalCtx == context.Background() {
51+
t.Errorf("expected passed in external context, got default")
52+
}
53+
})
54+
4455
t.Run("input options", func(t *testing.T) {
4556
exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
4657
p := NewProgram(nil, opt)

tea.go

+69-22
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import (
3232
"golang.org/x/sync/errgroup"
3333
)
3434

35+
// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
36+
var ErrProgramPanic = errors.New("program experienced a panic")
37+
3538
// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
3639
var ErrProgramKilled = errors.New("program was killed")
3740

@@ -210,6 +213,12 @@ type Program struct {
210213

211214
inputType inputType
212215

216+
// externalCtx is a context that was passed in via WithContext, otherwise defaulting
217+
// to ctx.Background() (in case it was not), the internal context is derived from it.
218+
externalCtx context.Context
219+
220+
// ctx is the programs's internal context for signalling internal teardown.
221+
// It is built and derived from the externalCtx in NewProgram().
213222
ctx context.Context
214223
cancel context.CancelFunc
215224

@@ -341,11 +350,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
341350

342351
// A context can be provided with a ProgramOption, but if none was provided
343352
// we'll use the default background context.
344-
if p.ctx == nil {
345-
p.ctx = context.Background()
353+
if p.externalCtx == nil {
354+
p.externalCtx = context.Background()
346355
}
347356
// Initialize context and teardown channel.
348-
p.ctx, p.cancel = context.WithCancel(p.ctx)
357+
p.ctx, p.cancel = context.WithCancel(p.externalCtx)
349358

350359
// if no output was set, set it to stdout
351360
if p.output == nil {
@@ -470,7 +479,11 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
470479
go func() {
471480
// Recover from panics.
472481
if !p.startupOptions.has(withoutCatchPanics) {
473-
defer p.recoverFromPanic()
482+
defer func() {
483+
if r := recover(); r != nil {
484+
p.recoverFromPanic(r)
485+
}
486+
}()
474487
}
475488

476489
msg := cmd() // this can be long.
@@ -686,7 +699,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
686699

687700
case BatchMsg:
688701
for _, cmd := range msg {
689-
cmds <- cmd
702+
select {
703+
case <-p.ctx.Done():
704+
return model, nil
705+
case cmds <- cmd:
706+
}
690707
}
691708
continue
692709

@@ -751,7 +768,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
751768

752769
var cmd Cmd
753770
model, cmd = model.Update(msg) // run update
754-
cmds <- cmd // process command (if any)
771+
772+
select {
773+
case <-p.ctx.Done():
774+
return model, nil
775+
case cmds <- cmd: // process command (if any)
776+
}
755777

756778
p.render(model) // render view
757779
}
@@ -789,11 +811,15 @@ func (p *Program) render(model Model) {
789811
// Run initializes the program and runs its event loops, blocking until it gets
790812
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
791813
// Returns the final model.
792-
func (p *Program) Run() (Model, error) {
814+
func (p *Program) Run() (returnModel Model, returnErr error) {
793815
p.handlers = channelHandlers{}
794816
cmds := make(chan Cmd)
795-
p.errs = make(chan error)
796-
p.finished = make(chan struct{}, 1)
817+
p.errs = make(chan error, 1)
818+
819+
p.finished = make(chan struct{})
820+
defer func() {
821+
close(p.finished)
822+
}()
797823

798824
defer p.cancel()
799825

@@ -842,7 +868,12 @@ func (p *Program) Run() (Model, error) {
842868

843869
// Recover from panics.
844870
if !p.startupOptions.has(withoutCatchPanics) {
845-
defer p.recoverFromPanic()
871+
defer func() {
872+
if r := recover(); r != nil {
873+
returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
874+
p.recoverFromPanic(r)
875+
}
876+
}()
846877
}
847878

848879
// Check if output is a TTY before entering raw mode, hiding the cursor and
@@ -986,11 +1017,27 @@ func (p *Program) Run() (Model, error) {
9861017

9871018
// Run event loop, handle updates and draw.
9881019
model, err := p.eventLoop(model, cmds)
989-
killed := p.ctx.Err() != nil || err != nil
990-
if killed && err == nil {
991-
err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())
1020+
1021+
if err == nil && len(p.errs) > 0 {
1022+
err = <-p.errs // Drain a leftover error in case eventLoop crashed.
9921023
}
993-
if err == nil {
1024+
1025+
killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
1026+
if killed {
1027+
if err == nil && p.externalCtx.Err() != nil {
1028+
// Return also as context error the cancellation of an external context.
1029+
// This is the context the user knows about and should be able to act on.
1030+
err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
1031+
} else if err == nil && p.ctx.Err() != nil {
1032+
// Return only that the program was killed (not the internal mechanism).
1033+
// The user does not know or need to care about the internal program context.
1034+
err = ErrProgramKilled
1035+
} else {
1036+
// Return that the program was killed and also the error that caused it.
1037+
err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
1038+
}
1039+
} else {
1040+
// Graceful shutdown of the program (not killed):
9941041
// Ensure we rendered the final state of the model.
9951042
p.render(model)
9961043
}
@@ -1068,9 +1115,6 @@ func (p *Program) shutdown(kill bool) {
10681115
}
10691116

10701117
_ = p.restoreTerminalState()
1071-
if !kill {
1072-
p.finished <- struct{}{}
1073-
}
10741118

10751119
// Print a final newline to ensure the terminal prompt is on a new line.
10761120
p.execute("\r\n")
@@ -1079,12 +1123,15 @@ func (p *Program) shutdown(kill bool) {
10791123

10801124
// recoverFromPanic recovers from a panic, prints the stack trace, and restores
10811125
// the terminal to a usable state.
1082-
func (p *Program) recoverFromPanic() {
1083-
if r := recover(); r != nil {
1084-
p.shutdown(true)
1085-
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
1086-
debug.PrintStack()
1126+
func (p *Program) recoverFromPanic(r interface{}) {
1127+
select {
1128+
case p.errs <- ErrProgramPanic:
1129+
default:
10871130
}
1131+
p.cancel() // Just in case a previous shutdown has failed.
1132+
p.shutdown(true)
1133+
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
1134+
debug.PrintStack()
10881135
}
10891136

10901137
// ReleaseTerminal restores the original terminal state and cancels the input

0 commit comments

Comments
 (0)