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

  1. Read the printed panic + stack trace (recoverFromPanic outputs it) and fix the offending line
  2. Re-run with tea.WithoutCatchPanics() for an unabridged crash dump if the recovered trace is truncated
  3. Write a regression test driving Update/View with the failing message sequence
  4. Check bubbles/lipgloss versions for known panics and upgrade
  5. 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

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


AI-assisted analysis of charmbracelet/bubbletea@351d2159f8 (2026-08-15). Data as JSON: /api/errors/e37305452b641f6c. Report an issue: GitHub.