Skip to content

(v2) tea.go: deadlock/error return fixes, additional tests (targeted at v2) #1388

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 8 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 @@ -21,7 +21,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 @@ -41,6 +42,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")
}
})

t.Run("input options", func(t *testing.T) {
exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
p := NewProgram(nil, opt)
Expand Down
91 changes: 69 additions & 22 deletions tea.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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 @@ -210,6 +213,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 @@ -341,11 +350,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 @@ -470,7 +479,11 @@
go func() {
// Recover from panics.
if !p.startupOptions.has(withoutCatchPanics) {
defer p.recoverFromPanic()
defer func() {
if r := recover(); r != nil {
p.recoverFromPanic(r)
}
}()
}

msg := cmd() // this can be long.
Expand Down Expand Up @@ -686,7 +699,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 @@ -751,7 +768,12 @@

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.render(model) // render view
}
Expand Down Expand Up @@ -789,11 +811,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 @@ -842,7 +868,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)
}
}()
}

// Check if output is a TTY before entering raw mode, hiding the cursor and
Expand Down Expand Up @@ -986,11 +1017,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.render(model)
}
Expand Down Expand Up @@ -1068,9 +1115,6 @@
}

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

// Print a final newline to ensure the terminal prompt is on a new line.
p.execute("\r\n")
Expand All @@ -1079,12 +1123,15 @@

// 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 1129 in tea.go

View check run for this annotation

Codecov / codecov/patch

tea.go#L1129

Added line #L1129 was not covered by tests
}
p.cancel() // Just in case a previous shutdown has failed.
p.shutdown(true)
fmt.Printf("Caught 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