Local sessions keep it in the config directory's recovery/ and write a report to crashes/, printing both paths once the terminal is back; 012 serve keeps it in .012-recovery/ and logs the report. e2e builds with crashtest and checks each kind of panic through libghostty.
- commit
- 14bbf83393bc7cb82df77b515d5b63be7070c19d
- parent
- 727d24fee1d12f66088b79ae342c9350dab42c33
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.8 of 3, Jev's estimate
| A | cmd/ | 78 | ++++++++++++++++++++++++++++++ |
| M | cmd/ | 11 | ++++- |
| M | docs/ | 32 | ++++++++++++ |
| M | docs/ | 5 | +- |
| A | e2e/ | 61 | +++++++++++++++++++++++ |
| M | e2e/ | 5 | +- |
| M | e2e/ | 2 | +- |
| M | internal/ | 26 | ++++++---- |
| M | internal/ | 28 | +++++++++++ |
| M | internal/ | 14 | ++++-- |
@@ -0,0 +1,78 @@
1+package main2+3+import (4+ "errors"5+ "fmt"6+ "path/filepath"7+ "strings"8+ "time"9+10+ "github.com/FelineStateMachine/012/internal/config"11+ "github.com/FelineStateMachine/012/internal/fileio"12+ "github.com/FelineStateMachine/012/internal/ui"13+)14+15+// When the UI stops on an internal error (a panic, which ui.Guard16+// catches), the terminal is already restored; what's left is to keep17+// the unsaved work where the next start on that file offers it back,18+// write a report, and say where both are. See docs/files/saving.md.19+20+// recoveryDir is where local sessions keep unsaved work after a crash,21+// <config.Dir>/recovery; "" when there's no config directory.22+func recoveryDir() string {23+ d, err := config.Dir()24+ if err != nil {25+ return ""26+ }27+ return filepath.Join(d, "recovery")28+}29+30+// crashDir is where crash reports go, <config.Dir>/crashes.31+func crashDir() string {32+ d, err := config.Dir()33+ if err != nil {34+ return ""35+ }36+ return filepath.Join(d, "crashes")37+}38+39+// offersKept reports whether a session started on args, reading40+// standard input or not, offers the changes a crash kept: when it opens41+// a .012 file or nothing, not when it imports a file or reads a table.42+func offersKept(args []string, stdin bool) bool {43+ if stdin {44+ return false45+ }46+ if len(args) == 1 {47+ if _, imports := fileio.KindOf(args[0]); imports {48+ return false49+ }50+ }51+ return true52+}53+54+// crashed keeps the unsaved work of the program g ran, writes the55+// report, and returns the error that tells the user where both are.56+func crashed(g *ui.Guarded, c *ui.Crash) error {57+ now := time.Now()58+ m := g.Model()59+ kept, keepErr := g.Keep(now)60+ lines := []string{fmt.Sprintf("stopped on an internal error (a panic in %s: %v)", c.Where, c.Value)}61+ switch {62+ case keepErr != nil:63+ lines = append(lines, "couldn't keep the unsaved changes: "+keepErr.Error())64+ case kept != "" && m.Filename() == "":65+ lines = append(lines, "unsaved changes kept in "+kept+"; start 012 again to restore them")66+ case kept != "":67+ lines = append(lines, "unsaved changes kept in "+kept+"; open "+m.Filename()+" again to restore them")68+ }69+ if dir := crashDir(); dir != "" {70+ report, err := c.Report(dir, buildVersion(), m.Filename(), kept, keepErr, now)71+ if err != nil {72+ lines = append(lines, "couldn't write a report: "+err.Error())73+ } else {74+ lines = append(lines, "a report is in "+report+"; include it when reporting the problem")75+ }76+ }77+ return errors.New(strings.Join(lines, "\n012: "))78+}@@ -128,6 +128,8 @@ func run(args []string, e env) error {
128128 Keys: keyStore(cfg, e),129129 Connect: func(key string) (jev.Client, error) { return jev.NewClient(jevConfig(cfg, key)) },130130 Notes: configNotes(cfg),131+ // Where a crash keeps unsaved work: crash.go.132+ RecoveryDir: recoveryDir(),131133 }132134 client, notes := startJEV(cfg, e, settings.Keys)133135 settings.Notes = append(settings.Notes, notes...)@@ -138,13 +140,20 @@ func run(args []string, e env) error {
138140 if notebook {139141 m.OpenShell()140142 }143+ if offersKept(args, pipe.stdin) {144+ m.OfferKept()145+ }141146 setPipe(m, pipe, e.stdin)142147 opts, closeTTY, err := tuiOptions(pipe, e)143148 if err != nil {144149 return err145150 }146- err = e.runTUI(m, opts...)151+ g := ui.Guard(m)152+ err = e.runTUI(g, opts...)147153 closeTTY()154+ if c := g.Finish(err); c != nil {155+ return crashed(g, c)156+ }148157 if err != nil {149158 return err150159 }@@ -17,3 +17,35 @@ Under `012 serve`, a session that idles out or is ended by the server
1717 stopping keeps its unsaved changes in `.012-recovery/` in the served1818 directory, and the next session opening that file offers them back: see1919 [Serving over SSH](../terminal/ssh.md#unsaved-work).20+21+## If 012 crashes22+23+An internal error (a bug, a Go panic) stops 012 rather than leaving24+the terminal broken: the screen goes back to what the shell showed,25+and 012 says what happened and where it put things:26+27+```28+012: stopped on an internal error (a panic in update: ...)29+012: unsaved changes kept in ~/.config/012/recovery/%2FUsers%2Fann%2Fbudget-20260929-101112.012; open budget.012 again to restore them30+012: a report is in ~/.config/012/crashes/crash-20260929-101112.txt; include it when reporting the problem31+```32+33+- **Unsaved changes** are written, the whole workbook, to `recovery/`34+ in 012's config directory (the one `012 config path` prints the35+ [config file](../reference/config.md) of, `~/.config/012` on Linux),36+ named after the file's full path. Opening that37+ file again, or starting `012` on nothing for a sheet never saved,38+ asks on the context line: Enter restores them as unsaved changes to39+ the file, D deletes them, Esc leaves them for next time. Saving a40+ restored workbook removes its recovery file. Nothing is kept without41+ unsaved changes, and only the newest three files per name.42+- **The report** goes to `crashes/` beside it: where the error43+ happened, the error, its stack of calls, 012's version, the system,44+ the file's name and where its changes went, never what the workbook45+ holds. The newest ten are kept.46+47+If the error happened while drawing the screen, the terminal also48+shows the stack of calls above those lines. Under `012 serve`, the49+session's changes go to `.012-recovery/` as above, the client is told50+that 012 stopped on an internal error, and the report goes to the51+server's log.@@ -163,8 +163,9 @@ authorized key may run programs on the server.
163163 ## Unsaved work164164 165165 A session that ends without the user quitting, because it was idle for166-`--idle-timeout` or because the server is stopping (Ctrl+C, SIGTERM),167-keeps its unsaved changes: the whole workbook is written to166+`--idle-timeout`, because the server is stopping (Ctrl+C, SIGTERM), or167+because 012 [stopped on an internal error](../files/saving.md#if-012-crashes)168+(whose report goes to the server's log), keeps its unsaved changes: the whole workbook is written to168169 `.012-recovery/<name>-<time>.012` in the served directory, and the169170 client's terminal is told where before the connection closes:170171 @@ -0,0 +1,61 @@
1+package e2e2+3+import (4+ "os"5+ "path/filepath"6+ "strings"7+ "testing"8+9+ ghostty "go.mitchellh.com/libghostty"10+)11+12+// A panic stops 012 with the terminal restored (the primary screen, the13+// cursor shown), the unsaved work kept, and a report written, their14+// paths printed. Opening the file again offers the work back. The15+// binary is built with -tags crashtest, where F12 panics where16+// O12_CRASH_TEST says (internal/ui/crashtest.go).17+func TestPanicKeepsWork(t *testing.T) {18+ for _, where := range []string{"update", "command", "view"} {19+ t.Run(where, func(t *testing.T) {20+ dir := t.TempDir()21+ o := options{dir: dir, env: []string{"O12_CRASH_TEST=" + where}}22+ s := startWith(t, o, "budget.012")23+ s.keys("kept", "<enter>")24+ s.waitFor("kept")25+ s.keys("<f12>")26+ s.waitExit()27+ if scr := s.activeScreen(); scr != ghostty.ScreenPrimary {28+ t.Errorf("active screen after the crash = %v, want primary", scr)29+ }30+ s.mu.Lock()31+ visible, _ := s.vt.CursorVisible()32+ s.mu.Unlock()33+ if !visible {34+ t.Error("the cursor is hidden after the crash")35+ }36+ text := strings.ReplaceAll(s.screen(), "\n", "")37+ recovery, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "recovery", "*budget-*.012"))38+ reports, _ := filepath.Glob(filepath.Join(dir, ".config", "012", "crashes", "crash-*.txt"))39+ if len(recovery) != 1 || len(reports) != 1 {40+ t.Fatalf("recovery files %v, reports %v; screen:\n%s", recovery, reports, s.screen())41+ }42+ for _, want := range []string{"012: stopped on an internal error (a panic in " + strings.Replace(where, "command", "a command", 1), "unsaved changes kept in " + recovery[0] + "; open budget.012 again to restore them",43+ "a report is in " + reports[0]} {44+ if !strings.Contains(text, want) {45+ t.Errorf("the screen lacks %q:\n%s", want, s.screen())46+ }47+ }48+ report, _ := os.ReadFile(reports[0])49+ if !strings.Contains(string(report), "Panic: crash test in ") {50+ t.Errorf("report:\n%s", report)51+ }52+53+ r := startWith(t, options{dir: dir, startsOn: "Unsaved changes to budget.012 were kept."}, "budget.012")54+ r.keys("<enter>")55+ r.waitFor("Restored the kept changes")56+ if !strings.Contains(r.line(gridRow1), "kept") {57+ t.Errorf("A1 after restoring: %q", r.line(gridRow1))58+ }59+ })60+ }61+}@@ -28,8 +28,9 @@ func TestMain(m *testing.M) {
2828 }2929 binPath = filepath.Join(dir, "012")3030 // fakekeyring swaps the OS credential store for a file under31- // XDG_CONFIG_HOME, which every session points at a temporary directory.32- build := exec.Command("go", "build", "-tags", "fakekeyring", "-o", binPath, "./cmd/012")31+ // XDG_CONFIG_HOME, which every session points at a temporary32+ // directory; crashtest makes F12 panic (crash_test.go).33+ build := exec.Command("go", "build", "-tags", "fakekeyring,crashtest", "-o", binPath, "./cmd/012")3334 build.Dir = ".."3435 build.Env = append(os.Environ(), "CGO_ENABLED=0")3536 if out, err := build.CombinedOutput(); err != nil {@@ -124,7 +124,7 @@ var namedKeys = map[string]ghostty.Key{
124124 "up": ghostty.KeyArrowUp, "down": ghostty.KeyArrowDown,125125 "left": ghostty.KeyArrowLeft, "right": ghostty.KeyArrowRight,126126 "pgup": ghostty.KeyPageUp, "pgdown": ghostty.KeyPageDown,127- "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f9": ghostty.KeyF9, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11,127+ "f1": ghostty.KeyF1, "f2": ghostty.KeyF2, "f5": ghostty.KeyF5, "f9": ghostty.KeyF9, "f10": ghostty.KeyF10, "f11": ghostty.KeyF11, "f12": ghostty.KeyF12,128128 "space": ghostty.KeySpace,129129 }130130 @@ -49,21 +49,23 @@ func (s *Server) fileArg(sess ssh.Session) (string, error) {
4949 5050 // ending is how a session ended, and where its unsaved work was kept.5151 type ending struct {52- idle, stopped bool // it idled out; the server stopped53- kept string // the recovery file, relative to the served directory54- keptFor string // the file name it was kept for, "" for a book never saved55- err error // keeping it failed52+ idle, stopped bool // it idled out; the server stopped53+ crash *ui.Crash // it stopped on an internal error54+ kept string // the recovery file, relative to the served directory55+ keptFor string // the file name it was kept for, "" for a book never saved56+ err error // keeping it failed5657 }5758 58-// keep writes m's unsaved work to a recovery file when the session ended59-// by idling out or by the server stopping; when the user quits, the app60-// has already asked about unsaved changes.61-func (e *ending) keep(m *ui.Model) {62- if !e.idle && !e.stopped || !m.Unsaved() {59+// keep writes the unsaved work of the program g ran to a recovery file60+// when the session ended by idling out, by the server stopping or on an61+// internal error; when the user quits, the app has already asked about62+// unsaved changes.63+func (e *ending) keep(g *ui.Guarded) {64+ if !e.idle && !e.stopped && e.crash == nil {6365 return6466 }65- e.keptFor = m.Filename()66- e.kept, e.err = m.Recover(time.Now())67+ e.keptFor = g.Model().Filename()68+ e.kept, e.err = g.Keep(time.Now())6769 }6870 6971 // tell says on the client's terminal why the session ended and where its@@ -74,6 +76,8 @@ func (e ending) tell(w io.Writer, idle time.Duration, root confine.Root) {
7476 fmt.Fprint(w, "012: the server is stopping\r\n")7577 case e.idle:7678 fmt.Fprintf(w, "012: closed after %v without input\r\n", idle)79+ case e.crash != nil:80+ fmt.Fprint(w, "012: stopped on an internal error; the server's log has a report\r\n")7781 }7882 switch {7983 case e.err != nil:@@ -11,9 +11,12 @@ import (
1111 "testing"1212 "time"1313 14+ tea "charm.land/bubbletea/v2"1415 gossh "golang.org/x/crypto/ssh"1516 17+ "github.com/FelineStateMachine/012/internal/confine"1618 "github.com/FelineStateMachine/012/internal/sheet"19+ "github.com/FelineStateMachine/012/internal/ui"1720 )1821 1922 // openExec starts a session with a terminal and an exec request for@@ -257,3 +260,28 @@ func TestRecoveryOnShutdown(t *testing.T) {
257260 io.WriteString(tm.stdin, "\x11")258261 tm.waitClosed(t)259262 }263+264+// A session that stopped on an internal error keeps its unsaved work,265+// however it ended, and tells the client the report is in the log.266+func TestRecoveryOnCrash(t *testing.T) {267+ dir := t.TempDir()268+ root, err := confine.New(dir)269+ if err != nil {270+ t.Fatal(err)271+ }272+ m := ui.New(sheet.New(), "book.012")273+ m.Serve(root, nil)274+ m.Update(tea.KeyPressMsg{Code: '7', Text: "7"})275+ m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})276+ e := ending{crash: &ui.Crash{Where: "update", Value: "boom"}}277+ e.keep(ui.Guard(m))278+ var out strings.Builder279+ e.tell(&out, time.Minute, root)280+ if e.err != nil || !strings.HasPrefix(e.kept, filepath.Join(".012-recovery", "book-")) {281+ t.Fatalf("kept %q, %v", e.kept, e.err)282+ }283+ if !strings.Contains(out.String(), "stopped on an internal error; the server's log has a report") ||284+ !strings.Contains(out.String(), "open book.012 again to restore them") {285+ t.Errorf("told %q", out.String())286+ }287+}@@ -310,7 +310,8 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
310310 m.EnableJEV(s.jev, jev.NewCache())311311 }312312 in := newActivity(sess)313- p := tea.NewProgram(m, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)313+ g := ui.Guard(m)314+ p := tea.NewProgram(g, append(bm.MakeOptions(sess), tea.WithInput(in), tea.WithFPS(ui.FrameRate))...)314315 315316 // The connection's context ends when the client goes away; cancel316317 // stops the goroutines below when the program quits first.@@ -333,12 +334,17 @@ func (s *Server) run(sess ssh.Session, pty ssh.Pty, winch <-chan ssh.Window, nam
333334 }334335 }()335336 }336- if _, err := p.Run(); err != nil && !errors.Is(err, tea.ErrProgramKilled) {337+ _, err := p.Run()338+ if err != nil && !errors.Is(err, tea.ErrProgramKilled) {337339 s.log.Error("session", "error", err.Error())338340 }339341 p.Kill()340- end := ending{idle: idled.Load(), stopped: s.stopping()}341- end.keep(m)342+ end := ending{idle: idled.Load(), stopped: s.stopping(), crash: g.Finish(err)}343+ if c := end.crash; c != nil {344+ // The report is the server's log: the operator's to read.345+ s.log.Error("session panic", "where", c.Where, "panic", fmt.Sprint(c.Value), "stack", string(c.Stack))346+ }347+ end.keep(g)342348 return end343349 }344350