Skip to content

Commit 915f65d

Browse files
authored
chore: merge pull request #1376 from desertwitch/stacked-patches
(v1-0/3) tea.go: all fixes combined (for convenience/CI)
2 parents 5a360c9 + e24d6b2 commit 915f65d

File tree

4 files changed

+346
-28
lines changed

4 files changed

+346
-28
lines changed

options.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type ProgramOption func(*Program)
1919
// cancelled it will exit with an error ErrProgramKilled.
2020
func WithContext(ctx context.Context) ProgramOption {
2121
return func(p *Program) {
22-
p.ctx = ctx
22+
p.externalCtx = ctx
2323
}
2424
}
2525

options_test.go

Lines changed: 11 additions & 0 deletions
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"
@@ -51,6 +52,16 @@ func TestOptions(t *testing.T) {
5152
}
5253
})
5354

55+
t.Run("external context", func(t *testing.T) {
56+
extCtx, extCancel := context.WithCancel(context.Background())
57+
defer extCancel()
58+
59+
p := NewProgram(nil, WithContext(extCtx))
60+
if p.externalCtx != extCtx || p.externalCtx == context.Background() {
61+
t.Errorf("expected passed in external context, got default (nil)")
62+
}
63+
})
64+
5465
t.Run("input options", func(t *testing.T) {
5566
exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
5667
p := NewProgram(nil, opt)

tea.go

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import (
2727
"golang.org/x/sync/errgroup"
2828
)
2929

30+
// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
31+
var ErrProgramPanic = errors.New("program experienced a panic")
32+
3033
// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
3134
var ErrProgramKilled = errors.New("program was killed")
3235

@@ -147,6 +150,12 @@ type Program struct {
147150

148151
inputType inputType
149152

153+
// externalCtx is a context that was passed in via WithContext, otherwise defaulting
154+
// to ctx.Background() (in case it was not), the internal context is derived from it.
155+
externalCtx context.Context
156+
157+
// ctx is the programs's internal context for signalling internal teardown.
158+
// It is built and derived from the externalCtx in NewProgram().
150159
ctx context.Context
151160
cancel context.CancelFunc
152161

@@ -243,11 +252,11 @@ func NewProgram(model Model, opts ...ProgramOption) *Program {
243252

244253
// A context can be provided with a ProgramOption, but if none was provided
245254
// we'll use the default background context.
246-
if p.ctx == nil {
247-
p.ctx = context.Background()
255+
if p.externalCtx == nil {
256+
p.externalCtx = context.Background()
248257
}
249258
// Initialize context and teardown channel.
250-
p.ctx, p.cancel = context.WithCancel(p.ctx)
259+
p.ctx, p.cancel = context.WithCancel(p.externalCtx)
251260

252261
// if no output was set, set it to stdout
253262
if p.output == nil {
@@ -346,7 +355,11 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} {
346355
go func() {
347356
// Recover from panics.
348357
if !p.startupOptions.has(withoutCatchPanics) {
349-
defer p.recoverFromPanic()
358+
defer func() {
359+
if r := recover(); r != nil {
360+
p.recoverFromGoPanic(r)
361+
}
362+
}()
350363
}
351364

352365
msg := cmd() // this can be long.
@@ -460,7 +473,11 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
460473

461474
case BatchMsg:
462475
for _, cmd := range msg {
463-
cmds <- cmd
476+
select {
477+
case <-p.ctx.Done():
478+
return model, nil
479+
case cmds <- cmd:
480+
}
464481
}
465482
continue
466483

@@ -506,7 +523,13 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
506523

507524
var cmd Cmd
508525
model, cmd = model.Update(msg) // run update
509-
cmds <- cmd // process command (if any)
526+
527+
select {
528+
case <-p.ctx.Done():
529+
return model, nil
530+
case cmds <- cmd: // process command (if any)
531+
}
532+
510533
p.renderer.write(model.View()) // send view to renderer
511534
}
512535
}
@@ -515,11 +538,15 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
515538
// Run initializes the program and runs its event loops, blocking until it gets
516539
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
517540
// Returns the final model.
518-
func (p *Program) Run() (Model, error) {
541+
func (p *Program) Run() (returnModel Model, returnErr error) {
519542
p.handlers = channelHandlers{}
520543
cmds := make(chan Cmd)
521-
p.errs = make(chan error)
522-
p.finished = make(chan struct{}, 1)
544+
p.errs = make(chan error, 1)
545+
546+
p.finished = make(chan struct{})
547+
defer func() {
548+
close(p.finished)
549+
}()
523550

524551
defer p.cancel()
525552

@@ -568,7 +595,12 @@ func (p *Program) Run() (Model, error) {
568595

569596
// Recover from panics.
570597
if !p.startupOptions.has(withoutCatchPanics) {
571-
defer p.recoverFromPanic()
598+
defer func() {
599+
if r := recover(); r != nil {
600+
returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
601+
p.recoverFromPanic(r)
602+
}
603+
}()
572604
}
573605

574606
// If no renderer is set use the standard one.
@@ -645,11 +677,27 @@ func (p *Program) Run() (Model, error) {
645677

646678
// Run event loop, handle updates and draw.
647679
model, err := p.eventLoop(model, cmds)
648-
killed := p.ctx.Err() != nil || err != nil
649-
if killed && err == nil {
650-
err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())
680+
681+
if err == nil && len(p.errs) > 0 {
682+
err = <-p.errs // Drain a leftover error in case eventLoop crashed
651683
}
652-
if err == nil {
684+
685+
killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
686+
if killed {
687+
if err == nil && p.externalCtx.Err() != nil {
688+
// Return also as context error the cancellation of an external context.
689+
// This is the context the user knows about and should be able to act on.
690+
err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
691+
} else if err == nil && p.ctx.Err() != nil {
692+
// Return only that the program was killed (not the internal mechanism).
693+
// The user does not know or need to care about the internal program context.
694+
err = ErrProgramKilled
695+
} else {
696+
// Return that the program was killed and also the error that caused it.
697+
err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
698+
}
699+
} else {
700+
// Graceful shutdown of the program (not killed):
653701
// Ensure we rendered the final state of the model.
654702
p.renderer.write(model.View())
655703
}
@@ -704,11 +752,11 @@ func (p *Program) Quit() {
704752
p.Send(Quit())
705753
}
706754

707-
// Kill stops the program immediately and restores the former terminal state.
755+
// Kill signals the program to stop immediately and restore the former terminal state.
708756
// The final render that you would normally see when quitting will be skipped.
709757
// [program.Run] returns a [ErrProgramKilled] error.
710758
func (p *Program) Kill() {
711-
p.shutdown(true)
759+
p.cancel()
712760
}
713761

714762
// Wait waits/blocks until the underlying Program finished shutting down.
@@ -717,7 +765,11 @@ func (p *Program) Wait() {
717765
}
718766

719767
// shutdown performs operations to free up resources and restore the terminal
720-
// to its original state.
768+
// to its original state. It is called once at the end of the program's lifetime.
769+
//
770+
// This method should not be called to signal the program to be killed/shutdown.
771+
// Doing so can lead to race conditions with the eventual call at the program's end.
772+
// As alternatives, the [Quit] or [Kill] convenience methods should be used instead.
721773
func (p *Program) shutdown(kill bool) {
722774
p.cancel()
723775

@@ -744,19 +796,30 @@ func (p *Program) shutdown(kill bool) {
744796
}
745797

746798
_ = p.restoreTerminalState()
747-
if !kill {
748-
p.finished <- struct{}{}
749-
}
750799
}
751800

752801
// recoverFromPanic recovers from a panic, prints the stack trace, and restores
753802
// the terminal to a usable state.
754-
func (p *Program) recoverFromPanic() {
755-
if r := recover(); r != nil {
756-
p.shutdown(true)
757-
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
758-
debug.PrintStack()
803+
func (p *Program) recoverFromPanic(r interface{}) {
804+
select {
805+
case p.errs <- ErrProgramPanic:
806+
default:
759807
}
808+
p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore.
809+
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
810+
debug.PrintStack()
811+
}
812+
813+
// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and
814+
// signals for the program to be killed and terminal restored to a usable state.
815+
func (p *Program) recoverFromGoPanic(r interface{}) {
816+
select {
817+
case p.errs <- ErrProgramPanic:
818+
default:
819+
}
820+
p.cancel()
821+
fmt.Printf("Caught goroutine panic:\n\n%s\n\nRestoring terminal...\n\n", r)
822+
debug.PrintStack()
760823
}
761824

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

0 commit comments

Comments
 (0)