Skip to content

(v1-0/3) tea.go: all fixes combined (for convenience/CI) #1376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Apr 20, 2025
2 changes: 1 addition & 1 deletion options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ type ProgramOption func(*Program)
// cancelled it will exit with an error ErrProgramKilled.
func WithContext(ctx context.Context) ProgramOption {
return func(p *Program) {
p.ctx = ctx
p.externalCtx = ctx
}
}

Expand Down
11 changes: 11 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tea

import (
"bytes"
"context"
"os"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -51,6 +52,16 @@ func TestOptions(t *testing.T) {
}
})

t.Run("external context", func(t *testing.T) {
extCtx, extCancel := context.WithCancel(context.Background())
defer extCancel()

p := NewProgram(nil, WithContext(extCtx))
if p.externalCtx != extCtx || p.externalCtx == context.Background() {
t.Errorf("expected passed in external context, got default (nil)")
}
})

t.Run("input options", func(t *testing.T) {
exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
p := NewProgram(nil, opt)
Expand Down
113 changes: 88 additions & 25 deletions tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"golang.org/x/sync/errgroup"
)

// ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic.
var ErrProgramPanic = errors.New("program experienced a panic")

// ErrProgramKilled is returned by [Program.Run] when the program gets killed.
var ErrProgramKilled = errors.New("program was killed")

Expand Down Expand Up @@ -147,6 +150,12 @@

inputType inputType

// externalCtx is a context that was passed in via WithContext, otherwise defaulting
// to ctx.Background() (in case it was not), the internal context is derived from it.
externalCtx context.Context

// ctx is the programs's internal context for signalling internal teardown.
// It is built and derived from the externalCtx in NewProgram().
ctx context.Context
cancel context.CancelFunc

Expand Down Expand Up @@ -243,11 +252,11 @@

// A context can be provided with a ProgramOption, but if none was provided
// we'll use the default background context.
if p.ctx == nil {
p.ctx = context.Background()
if p.externalCtx == nil {
p.externalCtx = context.Background()
}
// Initialize context and teardown channel.
p.ctx, p.cancel = context.WithCancel(p.ctx)
p.ctx, p.cancel = context.WithCancel(p.externalCtx)

// if no output was set, set it to stdout
if p.output == nil {
Expand Down Expand Up @@ -346,7 +355,11 @@
go func() {
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer p.recoverFromPanic()
defer func() {
if r := recover(); r != nil {
p.recoverFromGoPanic(r)
}
}()
}

msg := cmd() // this can be long.
Expand Down Expand Up @@ -460,7 +473,11 @@

case BatchMsg:
for _, cmd := range msg {
cmds <- cmd
select {
case <-p.ctx.Done():
return model, nil
case cmds <- cmd:
}
}
continue

Expand Down Expand Up @@ -506,7 +523,13 @@

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
}
}
Expand All @@ -515,11 +538,15 @@
// Run initializes the program and runs its event loops, blocking until it gets
// terminated by either [Program.Quit], [Program.Kill], or its signal handler.
// Returns the final model.
func (p *Program) Run() (Model, error) {
func (p *Program) Run() (returnModel Model, returnErr error) {
p.handlers = channelHandlers{}
cmds := make(chan Cmd)
p.errs = make(chan error)
p.finished = make(chan struct{}, 1)
p.errs = make(chan error, 1)

p.finished = make(chan struct{})
defer func() {
close(p.finished)
}()

defer p.cancel()

Expand Down Expand Up @@ -568,7 +595,12 @@

// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer p.recoverFromPanic()
defer func() {
if r := recover(); r != nil {
returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic)
p.recoverFromPanic(r)
}
}()
}

// If no renderer is set use the standard one.
Expand Down Expand Up @@ -645,11 +677,27 @@

// Run event loop, handle updates and draw.
model, err := p.eventLoop(model, cmds)
killed := p.ctx.Err() != nil || err != nil
if killed && err == nil {
err = fmt.Errorf("%w: %s", ErrProgramKilled, p.ctx.Err())

if err == nil && len(p.errs) > 0 {
err = <-p.errs // Drain a leftover error in case eventLoop crashed
}
if err == nil {

killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil
if killed {
if err == nil && p.externalCtx.Err() != nil {
// Return also as context error the cancellation of an external context.
// This is the context the user knows about and should be able to act on.
err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err())
} else if err == nil && p.ctx.Err() != nil {
// Return only that the program was killed (not the internal mechanism).
// The user does not know or need to care about the internal program context.
err = ErrProgramKilled
} else {
// Return that the program was killed and also the error that caused it.
err = fmt.Errorf("%w: %w", ErrProgramKilled, err)
}
} else {
// Graceful shutdown of the program (not killed):
// Ensure we rendered the final state of the model.
p.renderer.write(model.View())
}
Expand Down Expand Up @@ -704,11 +752,11 @@
p.Send(Quit())
}

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

// Wait waits/blocks until the underlying Program finished shutting down.
Expand All @@ -717,7 +765,11 @@
}

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

Expand All @@ -744,19 +796,30 @@
}

_ = p.restoreTerminalState()
if !kill {
p.finished <- struct{}{}
}
}

// recoverFromPanic recovers from a panic, prints the stack trace, and restores
// the terminal to a usable state.
func (p *Program) recoverFromPanic() {
if r := recover(); r != nil {
p.shutdown(true)
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
func (p *Program) recoverFromPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:

Check warning on line 806 in tea.go

View check run for this annotation

Codecov / codecov/patch

tea.go#L806

Added line #L806 was not covered by tests
}
p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore.
fmt.Printf("Caught panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}

// recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and
// signals for the program to be killed and terminal restored to a usable state.
func (p *Program) recoverFromGoPanic(r interface{}) {
select {
case p.errs <- ErrProgramPanic:
default:
}
p.cancel()
fmt.Printf("Caught goroutine panic:\n\n%s\n\nRestoring terminal...\n\n", r)
debug.PrintStack()
}

// ReleaseTerminal restores the original terminal state and cancels the input
Expand Down
Loading
Loading