cat / 012
clone

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

ui.Guard: a panic in Update, Init or a command quits cleanly; View's goes on to Bubble Tea

Dami committed 727d24f
The crash keeps where it was, the value and the stack; Keep writes the
unsaved work to a recovery file and Crash.Report a short report. Builds
tagged crashtest panic on F12 where O12_CRASH_TEST says.
commit
727d24fee1d12f66088b79ae342c9350dab42c33
parent
593087df4ad668d5a33c62368da8a47b365a2202
author
Dami <dami.etoile@gmail.com>
kind
feat risk score 1.7 of 3, Jev's estimate
3 files changed, +468 -0
Ainternal/ui/crash.go246++++++++++++++++++++++++++++++
Ainternal/ui/crash_test.go200++++++++++++++++++++++++
Ainternal/ui/crashtest.go22+++
A internal/ui/crash.go +246 -0
@@ -0,0 +1,246 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "runtime"9+ "runtime/debug"10+ "strings"11+ "time"12+13+ tea "charm.land/bubbletea/v2"14+)15+16+// 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.25+26+// Crash is a panic Guard caught.27+type 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+}32+33+// Guarded is a model run under Guard.34+type 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+}41+42+// Guard wraps m so a panic in it stops the program without losing the43+// terminal; see Finish for what's left afterwards.44+func Guard(m *Model) *Guarded {45+ return &Guarded{m: m}46+}47+48+// Model is the model guarded.49+func (g *Guarded) Model() *Model { return g.m }50+51+// crashMsg is a panic caught in a command, brought to Update.52+type crashMsg struct{ crash *Crash }53+54+// caught makes a Crash of a recovered panic.55+func caught(where string, r any) *Crash {56+ return &Crash{Where: where, Value: r, Stack: debug.Stack()}57+}58+59+// Init implements tea.Model.60+func (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+}69+70+// Update implements tea.Model.71+func (g *Guarded) Update(msg tea.Msg) (_ 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+ cmd = tea.Quit85+ }86+ }()87+ if crashTest != nil {88+ if c := g.test(crashTest(msg)); c != nil {89+ return g, c90+ }91+ }92+ next, cmd := g.m.Update(msg)93+ if m, ok := next.(*Model); ok {94+ g.m = m95+ }96+ return g, g.guard(cmd)97+}98+99+// View implements tea.Model. It keeps showing the last frame while the100+// program quits after a crash.101+func (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+}117+118+// guard wraps cmd so a panic in it comes to Update as a crashMsg, and119+// so do the commands of a batch it returns. A sequence's (tea.Sequence)120+// are run by Bubble Tea itself, which catches their panics: see Finish.121+func (g *Guarded) guard(cmd tea.Cmd) tea.Cmd {122+ if cmd == nil {123+ return nil124+ }125+ return func() (msg tea.Msg) {126+ defer func() {127+ if r := recover(); r != nil {128+ msg = crashMsg{caught("a command", r)}129+ }130+ }()131+ msg = cmd()132+ if batch, ok := msg.(tea.BatchMsg); ok {133+ out := make(tea.BatchMsg, len(batch))134+ for i, c := range batch {135+ out[i] = g.guard(c)136+ }137+ return out138+ }139+ return msg140+ }141+}142+143+// Finish is called once the program has stopped, with what Run144+// returned. It reports the crash that stopped it, if one did: one Guard145+// caught, or one only Bubble Tea caught (a panic in a sequence's146+// command), whose stack it printed on the terminal.147+func (g *Guarded) Finish(runErr error) *Crash {148+ if g.crash == nil && errors.Is(runErr, tea.ErrProgramPanic) {149+ g.crash = &Crash{Where: "a command", Value: "a panic Bubble Tea caught, its stack printed on the terminal"}150+ }151+ return g.crash152+}153+154+// Keep writes the model's unsaved work to a recovery file, returning its155+// name ("" when there was nothing unsaved). A model a panic left too156+// broken to write is reported as an error rather than panicking again.157+func (g *Guarded) Keep(now time.Time) (kept string, err error) {158+ defer func() {159+ if r := recover(); r != nil {160+ kept, err = "", fmt.Errorf("writing the workbook failed too: %v", r)161+ }162+ }()163+ if !g.m.Unsaved() {164+ return "", nil165+ }166+ return g.m.Recover(now)167+}168+169+// crashReportsKept is how many reports Report leaves in its directory;170+// older ones are removed.171+const crashReportsKept = 10172+173+// Report writes a short report of the crash into dir, for the user to174+// send with an issue, and returns its path. It says where the panic was,175+// what with, the stack, the build (version), the system, the file open176+// and where its unsaved work was kept; never the workbook's contents.177+func (c *Crash) Report(dir, version, file, kept string, keepErr error, now time.Time) (string, error) {178+ if err := os.MkdirAll(dir, 0o700); err != nil {179+ return "", err180+ }181+ var b strings.Builder182+ fmt.Fprintf(&b, "012 stopped on an internal error (a panic in %s)\n\n", c.Where)183+ fmt.Fprintf(&b, "Time: %s\n", now.Format(time.RFC3339))184+ fmt.Fprintf(&b, "Version: %s\n", version)185+ fmt.Fprintf(&b, "System: %s/%s, %s\n", runtime.GOOS, runtime.GOARCH, runtime.Version())186+ fmt.Fprintf(&b, "File: %s\n", orNone(file, "(untitled)"))187+ switch {188+ case keepErr != nil:189+ fmt.Fprintf(&b, "Kept: couldn't keep the unsaved changes: %v\n", keepErr)190+ default:191+ fmt.Fprintf(&b, "Kept: %s\n", orNone(kept, "nothing unsaved"))192+ }193+ fmt.Fprintf(&b, "\nPanic: %v\n", c.Value)194+ if len(c.Stack) > 0 {195+ fmt.Fprintf(&b, "\n%s", c.Stack)196+ }197+ path := filepath.Join(dir, "crash-"+now.Format(recoveryStamp)+".txt")198+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)199+ for i := 2; errors.Is(err, os.ErrExist) && i < 100; i++ {200+ path = filepath.Join(dir, fmt.Sprintf("crash-%s-%d.txt", now.Format(recoveryStamp), i))201+ f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)202+ }203+ if err != nil {204+ return "", err205+ }206+ _, err = f.WriteString(b.String())207+ if cerr := f.Close(); err == nil {208+ err = cerr209+ }210+ pruneReports(dir)211+ return path, err212+}213+214+// pruneReports removes the oldest crash reports in dir beyond215+// crashReportsKept. Their names sort by the time they were written.216+func pruneReports(dir string) {217+ names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))218+ for _, n := range names[:max(0, len(names)-crashReportsKept)] {219+ os.Remove(n)220+ }221+}222+223+func orNone(s, none string) string {224+ if s == "" {225+ return none226+ }227+ return s228+}229+230+// crashTest, when set (only in builds tagged crashtest, for the e2e231+// tests: crashtest.go), says where a message should make the program232+// panic: "update", "view", "command", or "" for nowhere.233+var crashTest func(tea.Msg) string234+235+// test panics where crashTest said, or returns a command that will.236+func (g *Guarded) test(where string) tea.Cmd {237+ switch where {238+ case "update":239+ panic("crash test in update")240+ case "view":241+ g.viewPanics = true242+ case "command":243+ return g.guard(func() tea.Msg { panic("crash test in a command") })244+ }245+ return nil246+}
A internal/ui/crash_test.go +200 -0
@@ -0,0 +1,200 @@
1+package ui2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+ "testing"10+ "time"11+12+ tea "charm.land/bubbletea/v2"13+)14+15+// panicOn makes F12 panic where, as the crashtest build does, for the16+// test.17+func panicOn(t *testing.T, where string) {18+ t.Helper()19+ crashTest = func(msg tea.Msg) string {20+ if k, ok := msg.(tea.KeyPressMsg); ok && k.Code == tea.KeyF12 {21+ return where22+ }23+ return ""24+ }25+ t.Cleanup(func() { crashTest = nil })26+}27+28+var f12 = tea.KeyPressMsg{Code: tea.KeyF12}29+30+// isQuit reports whether cmd quits the program.31+func isQuit(cmd tea.Cmd) bool {32+ if cmd == nil {33+ return false34+ }35+ _, ok := cmd().(tea.QuitMsg)36+ return ok37+}38+39+// A panic in Update quits the program, and the crash says where and40+// what with; the model's unsaved work is kept in the recovery directory41+// and offered back when the file is opened again.42+func TestGuardUpdatePanicKeepsWork(t *testing.T) {43+ t.Chdir(t.TempDir())44+ dir := filepath.Join(t.TempDir(), "recovery")45+ panicOn(t, "update")46+ m := newModel()47+ m.prefs.RecoveryDir = dir48+ m.filename = "book.012"49+ press(t, m, "mine", "<enter>")50+ g := Guard(m)51+ if _, cmd := g.Update(f12); !isQuit(cmd) {52+ t.Fatal("a panic in Update didn't quit")53+ }54+ if _, cmd := g.Update(tea.KeyPressMsg{Code: 'x', Text: "x"}); cmd != nil {55+ t.Error("a crashed model took more input")56+ }57+ c := g.Finish(nil)58+ if c == nil || c.Where != "update" || fmt.Sprint(c.Value) != "crash test in update" || !strings.Contains(string(c.Stack), "crash.go") {59+ t.Fatalf("crash %+v", c)60+ }61+ kept, err := g.Keep(time.Now())62+ if err != nil || filepath.Dir(kept) != dir || !strings.Contains(filepath.Base(kept), "book-") {63+ t.Fatalf("kept %q, %v", kept, err)64+ }65+66+ again := New(m.sheet, "book.012") // the file on disk doesn't matter here67+ again.Update(tea.WindowSizeMsg{Width: 80, Height: 20})68+ again.prefs.RecoveryDir = dir69+ again.OfferKept()70+ run(again, again.Init())71+ if !strings.Contains(line(again, contextLine), "Unsaved changes to book.012 were kept.") {72+ t.Fatalf("no offer: %q", line(again, contextLine))73+ }74+ // Another directory's book.012 is another file.75+ other := newModel()76+ other.prefs.RecoveryDir = dir77+ t.Chdir(t.TempDir())78+ other.filename = "book.012"79+ other.OfferKept()80+ run(other, other.Init())81+ if strings.Contains(line(other, contextLine), "were kept") {82+ t.Errorf("offered another directory's book: %q", line(other, contextLine))83+ }84+}85+86+// Nothing unsaved, nothing kept.87+func TestGuardKeepsOnlyUnsaved(t *testing.T) {88+ m := newModel()89+ m.prefs.RecoveryDir = t.TempDir()90+ if kept, err := Guard(m).Keep(time.Now()); kept != "" || err != nil {91+ t.Errorf("kept %q, %v", kept, err)92+ }93+}94+95+// A panic in a command, or in one of a batch it returns, comes back to96+// Update as a crash, which quits.97+func TestGuardCommandPanic(t *testing.T) {98+ panicOn(t, "command")99+ g := Guard(newModel())100+ _, cmd := g.Update(f12)101+ msg := cmd()102+ if _, ok := msg.(crashMsg); !ok {103+ t.Fatalf("got %T", msg)104+ }105+ if _, cmd := g.Update(msg); !isQuit(cmd) {106+ t.Error("a crash didn't quit")107+ }108+ if c := g.Finish(nil); c == nil || c.Where != "a command" {109+ t.Errorf("crash %+v", c)110+ }111+112+ g = Guard(newModel())113+ fine := func() tea.Msg { return nil }114+ boom := func() tea.Msg { panic("in a batch") }115+ batch, ok := g.guard(tea.Batch(fine, boom))().(tea.BatchMsg)116+ if !ok || len(batch) != 2 {117+ t.Fatalf("batch %v", batch)118+ }119+ if c, ok := batch[1]().(crashMsg); !ok || fmt.Sprint(c.crash.Value) != "in a batch" {120+ t.Errorf("a batch's panic: %v", batch[1]())121+ }122+}123+124+// A panic in View goes on to Bubble Tea, to stop the program, once the125+// crash is noted; the last frame is shown after it.126+func TestGuardViewPanic(t *testing.T) {127+ panicOn(t, "view")128+ g := Guard(newModel())129+ before := g.View()130+ g.Update(f12)131+ func() {132+ defer func() {133+ if recover() == nil {134+ t.Error("View didn't panic")135+ }136+ }()137+ g.View()138+ }()139+ if c := g.Finish(errors.Join(tea.ErrProgramKilled, tea.ErrProgramPanic)); c == nil || c.Where != "view" {140+ t.Fatalf("crash %+v", c)141+ }142+ if g.View().Content != before.Content {143+ t.Error("after the crash, View isn't the last frame")144+ }145+}146+147+// A panic only Bubble Tea caught is still a crash.148+func TestGuardFinishBubbleTeaPanic(t *testing.T) {149+ g := Guard(newModel())150+ if c := g.Finish(nil); c != nil {151+ t.Errorf("a clean quit crashed: %+v", c)152+ }153+ if c := g.Finish(tea.ErrProgramPanic); c == nil || c.Where != "a command" {154+ t.Errorf("crash %+v", c)155+ }156+}157+158+// The report says where, what with, the build, the file and where its159+// work went, with the stack; at most crashReportsKept are left.160+func TestCrashReport(t *testing.T) {161+ dir := t.TempDir()162+ c := &Crash{Where: "update", Value: "boom", Stack: []byte("goroutine 1 [running]:\nmain.main()\n")}163+ at := time.Date(2026, 9, 29, 10, 11, 12, 0, time.Local)164+ path, err := c.Report(dir, "v9.9.9", "book.012", "/r/book-1.012", nil, at)165+ if err != nil {166+ t.Fatal(err)167+ }168+ if filepath.Base(path) != "crash-20260929-101112.txt" {169+ t.Errorf("path %s", path)170+ }171+ data, _ := os.ReadFile(path)172+ for _, want := range []string{"a panic in update", "Version: v9.9.9", "File: book.012", "Kept: /r/book-1.012", "Panic: boom", "main.main()"} {173+ if !strings.Contains(string(data), want) {174+ t.Errorf("report lacks %q:\n%s", want, data)175+ }176+ }177+ for i := range crashReportsKept + 3 {178+ if _, err := c.Report(dir, "", "", "", errors.New("disk full"), at.Add(time.Duration(i+1)*time.Second)); err != nil {179+ t.Fatal(err)180+ }181+ }182+ names, _ := filepath.Glob(filepath.Join(dir, "crash-*.txt"))183+ if len(names) != crashReportsKept || filepath.Base(names[0]) != "crash-20260929-101116.txt" {184+ t.Errorf("kept %d reports, first %s", len(names), filepath.Base(names[0]))185+ }186+ data, _ = os.ReadFile(names[0])187+ if !strings.Contains(string(data), "couldn't keep the unsaved changes: disk full") || !strings.Contains(string(data), "(untitled)") {188+ t.Errorf("report:\n%s", data)189+ }190+}191+192+// A path too long for a file name still makes a key that fits, and193+// different long paths make different keys.194+func TestRecoveryKeyLong(t *testing.T) {195+ long := "/" + strings.Repeat("dir/", 80)196+ a, b := recoveryKey(long+"a.012"), recoveryKey(long+"b.012")197+ if len(a) > recoveryKeyMax || a == b || !strings.HasSuffix(a, "a") {198+ t.Errorf("keys %q, %q", a, b)199+ }200+}
A internal/ui/crashtest.go +22 -0