cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / crash.go
crash.go 7.7K
1package ui23import (4 "errors"5 "fmt"6 "os"7 "path/filepath"8 "runtime"9 "runtime/debug"10 "strings"11 "time"1213 tea "charm.land/bubbletea/v2"14)1516// Crashes: a panic anywhere in the model (Update, View, Init, or a17// command it started) stops the program without taking the terminal18// down with it. Guard wraps the model for Bubble Tea: it recovers the19// panic, remembers where it was and what with, and quits, so Bubble Tea20// restores the terminal as on any quit. View can't return a command to21// quit with, so a panic there goes on to Bubble Tea, which restores the22// terminal itself and prints the stack. The caller then keeps the23// unsaved work for recovery (Keep, recovery.go) and writes a report24// (Crash.Report). See docs/files/saving.md.2526// Crash is a panic Guard caught.27type Crash struct {28 Where string // "update", "view", "init" or "a command"29 Value any // what was panicked with30 Stack []byte // the panicking goroutine's stack; nil when Bubble Tea caught it31}3233// Guarded is a model run under Guard.34type Guarded struct {35 m *Model36 crash *Crash37 last tea.View // the last frame drawn, shown until the program quits38 // viewPanics makes View panic, for crashTest.39 viewPanics bool40}4142// Guard wraps m so a panic in it stops the program without losing the43// terminal; see Finish for what's left afterwards.44func Guard(m *Model) *Guarded {45 return &Guarded{m: m}46}4748// Model is the model guarded.49func (g *Guarded) Model() *Model { return g.m }5051// crashMsg is a panic caught in a command, brought to Update.52type crashMsg struct{ crash *Crash }5354// caught makes a Crash of a recovered panic.55func caught(where string, r any) *Crash {56 return &Crash{Where: where, Value: r, Stack: debug.Stack()}57}5859// Init implements tea.Model.60func (g *Guarded) Init() (cmd tea.Cmd) {61 defer func() {62 if r := recover(); r != nil {63 g.crash = caught("init", r)64 cmd = tea.Quit65 }66 }()67 return g.guard(g.m.Init())68}6970// Update implements tea.Model.71func (g *Guarded) Update(msg tea.Msg) (next tea.Model, cmd tea.Cmd) {72 if c, ok := msg.(crashMsg); ok {73 if g.crash == nil {74 g.crash = c.crash75 }76 return g, tea.Quit77 }78 if g.crash != nil {79 return g, nil // quitting; the model may be broken80 }81 defer func() {82 if r := recover(); r != nil {83 g.crash = caught("update", r)84 next, cmd = g, tea.Quit // never nil: Bubble Tea draws what Update returns85 }86 }()87 if crashTest != nil {88 if c := g.test(crashTest(msg)); c != nil {89 return g, c90 }91 }92 inner, cmd := g.m.Update(msg)93 if m, ok := inner.(*Model); ok {94 g.m = m95 }96 return g, g.guard(cmd)97}9899// View implements tea.Model. It keeps showing the last frame while the100// program quits after a crash.101func (g *Guarded) View() tea.View {102 if g.crash != nil {103 return g.last104 }105 defer func() {106 if r := recover(); r != nil {107 g.crash = caught("view", r)108 panic(r) // for Bubble Tea to stop the program: see above109 }110 }()111 if g.viewPanics {112 panic("crash test in view")113 }114 g.last = g.m.View()115 return g.last116}117118// do runs fn on the model as Update would, a panic in it stopping the119// program the same way: for what Shared does to the model between120// turns.121func (g *Guarded) do(fn func() tea.Cmd) (cmd tea.Cmd) {122 if g.crash != nil {123 return nil124 }125 defer func() {126 if r := recover(); r != nil {127 g.crash = caught("update", r)128 cmd = tea.Quit129 }130 }()131 return g.guard(fn())132}133134// guard wraps cmd so a panic in it comes to Update as a crashMsg, and135// so do the commands of a batch it returns. A sequence's (tea.Sequence)136// are run by Bubble Tea itself, which catches their panics: see Finish.137func (g *Guarded) guard(cmd tea.Cmd) tea.Cmd {138 if cmd == nil {139 return nil140 }141 return func() (msg tea.Msg) {142 defer func() {143 if r := recover(); r != nil {144 msg = crashMsg{caught("a command", r)}145 }146 }()147 msg = cmd()148 if batch, ok := msg.(tea.BatchMsg); ok {149 out := make(tea.BatchMsg, len(batch))150 for i, c := range batch {151 out[i] = g.guard(c)152 }153 return out154 }155 return msg156 }157}158159// Finish is called once the program has stopped, with what Run160// returned. It reports the crash that stopped it, if one did: one Guard161// caught, or one only Bubble Tea caught (a panic in a sequence's162// command), whose stack it printed on the terminal.163func (g *Guarded) Finish(runErr error) *Crash {164 if g.m.share.seat == nil {165 g.m.closeStreams() // a stream runs until it's stopped; a room's, until the last one leaves (LeaveRoom)166 }167 if g.crash == nil && errors.Is(runErr, tea.ErrProgramPanic) {168 g.crash = &Crash{Where: "a command", Value: "a panic Bubble Tea caught, its stack printed on the terminal"}169 }170 return g.crash171}172173// Keep writes the model's unsaved work to a recovery file, returning its174// name ("" when there was nothing unsaved). A model a panic left too175// broken to write is reported as an error rather than panicking again.176func (g *Guarded) Keep(now time.Time) (kept string, err error) {177 defer func() {178 if r := recover(); r != nil {179 kept, err = "", fmt.Errorf("writing the workbook failed too: %v", r)180 }181 }()182 if !g.m.Unsaved() {183 return "", nil184 }185 return g.m.Recover(now)186}187188// crashReportsKept is how many reports Report leaves in its directory;189// older ones are removed.190const crashReportsKept = 10191192// Report writes a short report of the crash into dir, for the user to193// send with an issue, and returns its path. It says where the panic was,194// what with, the stack, the build (version), the system, the file open195// and where its unsaved work was kept; never the workbook's contents.196func (c *Crash) Report(dir, version, file, kept string, keepErr error, now time.Time) (string, error) {197 if err := os.MkdirAll(dir, 0o700); err != nil {198 return "", err199 }200 var b strings.Builder201 fmt.Fprintf(&b, "012 stopped on an internal error (a panic in %s)\n\n", c.Where)202 fmt.Fprintf(&b, "Time: %s\n", now.Format(time.RFC3339))203 fmt.Fprintf(&b, "Version: %s\n", version)204 fmt.Fprintf(&b, "System: %s/%s, %s\n", runtime.GOOS, runtime.GOARCH, runtime.Version())205 fmt.Fprintf(&b, "File: %s\n", orNone(file, "(untitled)"))206 switch {207 case keepErr != nil:208 fmt.Fprintf(&b, "Kept: couldn't keep the unsaved changes: %v\n", keepErr)209 default:210 fmt.Fprintf(&b, "Kept: %s\n", orNone(kept, "nothing unsaved"))211 }212 fmt.Fprintf(&b, "\nPanic: %v\n", c.Value)213 if len(c.Stack) > 0 {214 fmt.Fprintf(&b, "\n%s", c.Stack)215 }216 path := filepath.Join(dir, "crash-"+now.Format(recoveryStamp)+".txt")217 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)218 for i := 2; errors.Is(err, os.ErrExist) && i < 100; i++ {219 path = filepath.Join(dir, fmt.Sprintf("crash-%s-%d.txt", now.Format(recoveryStamp), i))220 f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)221 }222 if err != nil {223 return "", err224 }225 _, err = f.WriteString(b.String())226 if cerr := f.Close(); err == nil {227 err = cerr228 }229 pruneReports(dir)230 return path, err231}232233// pruneReports removes the oldest crash reports in dir beyond234// crashReportsKept. Their names sort by the time they were written.235func pruneReports(dir string) {236 names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))237 for _, n := range names[:max(0, len(names)-crashReportsKept)] {238 os.Remove(n)239 }240}241242func orNone(s, none string) string {243 if s == "" {244 return none245 }246 return s247}248249// crashTest, when set (only in builds tagged crashtest, for the e2e250// tests: crashtest.go), says where a message should make the program251// panic: "update", "view", "command", or "" for nowhere.252var crashTest func(tea.Msg) string253254// test panics where crashTest said, or returns a command that will.255func (g *Guarded) test(where string) tea.Cmd {256 switch where {257 case "update":258 panic("crash test in update")259 case "view":260 g.viewPanics = true261 case "command":262 return g.guard(func() tea.Msg { panic("crash test in a command") })263 }264 return nil265}