charmbracelet/bubbletea · critical · ErrProgramKilled/ErrProgramPanic
%w: %w
Error message
%w: %w
What it means
The exact error string produced at tea.go:1029 when Run's deferred recover() catches a panic: the returned error wraps both ErrProgramKilled and ErrProgramPanic, so errors.Is matches either sentinel. The program still calls recoverFromPanic, which restores the terminal and prints/logs the panic value and stack. This is the wrapped form of errors 0 and 1 appearing together.
Source
Thrown at tea.go:1029
if !term.IsTerminal(os.Stdin.Fd()) {
ttyIn, _, err := OpenTTY()
if err != nil {
return p.initialModel, fmt.Errorf("bubbletea: error opening TTY: %w", err)
}
p.input = ttyIn
}
}
// Handle signals.
if !p.disableSignalHandler {
p.handlers.add(p.handleSignals())
}
// Recover from panics.
if !p.disableCatchPanics {
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
// so on.
if err := p.initTerminal(); err != nil {
return p.initialModel, err
}
// Get the initial window size.
width, height := p.width, p.height
if p.ttyOutput != nil {
// Set the initial size of the terminal.
w, h, err := term.GetSize(p.ttyOutput.Fd())
if err != nil {
return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err)View on GitHub (pinned to 351d2159f8)
Solutions
- Read the printed panic + stack trace (recoverFromPanic outputs it) and fix the offending line
- Re-run with tea.WithoutCatchPanics() for an unabridged crash dump if the recovered trace is truncated
- Write a regression test driving Update/View with the failing message sequence
- Check bubbles/lipgloss versions for known panics and upgrade
- Report library-internal panics upstream with the stack
Example fix
// before
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width // but m is *model nil-able
}
return m, nil
}
// after
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m == nil { return &model{}, nil }
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
}
return m, nil
} Defensive patterns
Strategy: try-catch
Validate before calling
// Nothing to pre-validate; instead run with a logger so the panic trace is preserved:
// f, _ := tea.LogToFile("debug.log", "debug")
// p := tea.NewProgram(m, tea.WithLogger(log.New(f, "", 0))) Type guard
func isKilledPanic(err error) bool {
return err != nil && errors.Is(err, tea.ErrProgramPanic) && errors.Is(err, tea.ErrProgramKilled)
} Try / catch
_, err := p.Run()
if isKilledPanic(err) {
// stack already printed; exit with panic status
os.Exit(2)
} Prevention
- Fuzz Update with random message orders in CI
- Check nil receivers on pointer models
- Keep View pure and total over the model state
- Update bubbles/lipgloss together with bubbletea to avoid API-mismatch panics
When it happens
Trigger: A panic anywhere in the Run call path after the recover is installed: eventLoop, Update, View, renderer calls, command execution. Only when catch-panics is enabled (default) — with WithoutCatchPanics the process crashes instead and no error is returned.
Common situations: Nil dereference in Update on an edge-case message; View returning inconsistent state after an error path; panics in third-party lipgloss/bubbles components; nil map assignment during Init; regressions found only via a rare message sequence.
Related errors
- program experienced a panic
- program was killed
- program was interrupted
- bubbletea: InitialModel cannot be nil
- bubbletea: error closing screen writer: %w
AI-assisted analysis of charmbracelet/bubbletea@351d2159f8 (2026-08-15).
Data as JSON: /api/errors/e37305452b641f6c.
Report an issue: GitHub.