- commit
- a631dfa5203124ce6413a81cbc376a78f79b321a
- parent
- 8ab2d9dd3d5a8a8c72131f7dcc9a9a6d626ab554
- parent
- f7a7184aa3a5f4eaf64d6daef9427676c884636b
- author
- Dami <dami.etoile@gmail.com>
| A | cmd/ | 78 | ++++++++++ |
| M | cmd/ | 11 | +- |
| M | docs/ | 5 | +- |
| M | docs/ | 32 | ++++ |
| M | docs/ | 5 | +- |
| A | e2e/ | 61 | +++++++ |
| M | e2e/ | 5 | +- |
| M | e2e/ | 2 | +- |
| M | internal/ | 26 | ++- |
| M | internal/ | 28 | +++ |
| M | internal/ | 14 | +- |
| A | internal/ | 246 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 200 | ++++++++++++++++++++++++ |
| A | internal/ | 22 | +++ |
| M | internal/ | 1 | + |
| M | internal/ | 4 | + |
| M | internal/ | 86 | ++++++++-- |
| M | internal/ | 5 | +- |
@@ -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 }@@ -62,7 +62,10 @@ that macros' trust uses are the test's own: sessions that share a
6262 directory are one computer. The binary is built with `-tags6363 fakekeyring`, whose credential store is a file there, so tests never6464 read your config or touch your keychain. Unit tests do the same with65-`ui.Settings` and `keyring.Memory`.65+`ui.Settings` and `keyring.Memory`. The binary is also built with `-tags66+crashtest`, where F12 panics in the update, the view or a command, as67+the session's environment says (`internal/ui/crashtest.go`), so `e2e/crash_test.go` can check that a crash68+restores the terminal and keeps the work; release builds have no such key.6669 6770 `e2e/ssh_test.go` runs `012 serve` and reaches it with the system's6871 `ssh` client inside the same libghostty terminal, so the server path@@ -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 @@ -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+}@@ -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+}@@ -0,0 +1,22 @@
1+//go:build crashtest2+3+package ui4+5+import (6+ "os"7+8+ tea "charm.land/bubbletea/v2"9+)10+11+// In builds tagged crashtest (the e2e tests' binary), F12 makes the12+// program panic where O12_CRASH_TEST says: "update", "view" or13+// "command". Release builds have no such key.14+func init() {15+ where := os.Getenv("O12_CRASH_TEST")16+ crashTest = func(msg tea.Msg) string {17+ if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "f12" {18+ return where19+ }20+ return ""21+ }22+}@@ -84,6 +84,7 @@ type Model struct {
8484 root confine.Root // where file names resolve; confined when served over SSH8585 start *string // the file a served session opens first (OpenOnStart); nil once opened8686 recovered string // the recovery file restored into this book, removed once it's saved: recovery.go87+ offerKept bool // offer the recovery file kept for the file once started (OfferKept)8788 8889 mode mode8990 protectOK bool // an edit to a protected range was agreed to: protect.go@@ -31,6 +31,10 @@ type Settings struct {
3131 // Connect makes a JEV client for the configured service with key.3232 Connect func(key string) (jev.Client, error)3333 Notes []string // said on the context line at startup, e.g. config warnings34+ // RecoveryDir is where a local session keeps its unsaved work when35+ // it stops on an internal error, and finds it again: recovery.go.36+ // "" keeps none; 012 serve keeps them in the served directory.37+ RecoveryDir string3438 }3539 3640 // prefs is the settings in effect: the component behind File >@@ -2,6 +2,8 @@ package ui
22 33 import (44 "bytes"5+ "crypto/sha256"6+ "encoding/hex"57 "errors"68 "fmt"79 "io/fs"@@ -27,6 +29,11 @@ import (
2729 // back. The directory is hidden, so names typed in a session never reach2830 // it (internal/confine); only this file reads and writes it. See2931 // docs/terminal/ssh.md.32+//33+// A local session keeps them too when it stops on an internal error34+// (crash.go), in Settings.RecoveryDir, keyed by the file's absolute path,35+// and offers them back when that file is opened again. See36+// docs/files/saving.md.3037 3138 // RecoveryDir is the directory, inside the served one, recovery files3239 // are kept in.@@ -36,6 +43,9 @@ const (
3643 recoveryKeep = 3 // recovery files kept per file name; older ones are removed3744 recoveryUntitled = "(untitled)" // the key for a book never saved; "(" is escaped in file names3845 recoveryStamp = "20060102-150405"46+ // recoveryKeyMax keeps a recovery file's name (the key, the time and47+ // .012) under the 255 bytes file systems allow.48+ recoveryKeyMax = 2003949 )4050 4151 // recoveryName matches a recovery file's name after its key and "-":@@ -50,9 +60,19 @@ func (m *Model) OpenOnStart(name string) {
5060 m.start = &name5161 }5262 63+// OfferKept makes a local session offer, once it starts, the changes64+// kept for its file (or for an untitled book) when 012 last stopped on65+// an internal error. cmd/012 asks for it when started on a file or on66+// nothing, not on an import or standard input.67+func (m *Model) OfferKept() { m.offerKept = true }68+5369 // startOpenCmd opens what OpenOnStart set, once, when the program starts.5470 func (m *Model) startOpenCmd() tea.Cmd {5571 if m.start == nil {72+ if m.offerKept {73+ m.offerKept = false74+ m.offerRecovery(m.filename)75+ }5676 return nil5777 }5878 name := *m.start@@ -101,22 +121,19 @@ func (m *Model) Unsaved() bool {
101121 func (m *Model) Filename() string { return m.filename }102122 103123 // Recover writes the workbook to a new recovery file for its name,104-// removing the oldest beyond recoveryKeep, and returns the file's name105-// relative to the served directory. It's for 012 serve, when the program106-// has stopped; a model that isn't served has nowhere to put one.124+// removing the oldest beyond recoveryKeep, and returns the file's name:125+// relative to the served directory in 012 serve, its full path in a126+// local session. It's for when the program has stopped; a local model127+// without Settings.RecoveryDir has nowhere to put one.107128 func (m *Model) Recover(now time.Time) (string, error) {108- if !m.root.Confined() {109- return "", errors.New("recovery files are only kept for served sessions")129+ dir, key, err := m.recoveryPlace(m.filename, true)130+ if err != nil {131+ return "", err110132 }111133 var buf bytes.Buffer112134 if err := m.sheet.Write(&buf); err != nil {113135 return "", err114136 }115- dir, err := recoveryDir(m.root.Dir(), true)116- if err != nil {117- return "", err118- }119- key := recoveryKey(m.filename)120137 base := key + "-" + now.Format(recoveryStamp)121138 for i := 1; i < 100; i++ {122139 name := base + sheet.FileExt@@ -139,11 +156,41 @@ func (m *Model) Recover(now time.Time) (string, error) {
139156 return "", err140157 }141158 prune(dir, key)159+ if !m.root.Confined() {160+ return filepath.Join(dir, name), nil161+ }142162 return filepath.Join(RecoveryDir, name), nil143163 }144164 return "", errors.New("too many recovery files this second")145165 }146166 167+// recoveryPlace is the directory recovery files for name are kept in,168+// made when create is set, and the start of their names there.169+func (m *Model) recoveryPlace(name string, create bool) (dir, key string, err error) {170+ if m.root.Confined() {171+ dir, err = recoveryDir(m.root.Dir(), create)172+ return dir, recoveryKey(name), err173+ }174+ dir = m.prefs.RecoveryDir175+ if dir == "" {176+ return "", "", errors.New("this session keeps no recovery files")177+ }178+ if create {179+ if err := os.MkdirAll(dir, 0o700); err != nil {180+ return "", "", err181+ }182+ }183+ if st, err := os.Stat(dir); err != nil || !st.IsDir() {184+ return "", "", fmt.Errorf("no recovery directory at %s", dir)185+ }186+ if name != "" {187+ if abs, err := filepath.Abs(name); err == nil {188+ name = abs189+ }190+ }191+ return dir, recoveryKey(name), nil192+}193+147194 // recoveryDir is the recovery directory in root, made 0700 when create148195 // is set. It must be a directory of its own, not a link elsewhere.149196 func recoveryDir(root string, create bool) (string, error) {@@ -165,13 +212,19 @@ func recoveryDir(root string, create bool) (string, error) {
165212 166213 // recoveryKey is the start of the recovery files' names for a file name167214 // as typed: the name without its extension, escaped so any path is one168-// file name, or recoveryUntitled for a book never saved.215+// file name, or recoveryUntitled for a book never saved. A key too long216+// for a file name keeps its end after a hash of the whole.169217 func recoveryKey(name string) string {170218 if name == "" {171219 return recoveryUntitled172220 }173221 name = filepath.ToSlash(filepath.Clean(name))174- return url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))222+ key := url.PathEscape(strings.TrimSuffix(name, filepath.Ext(name)))223+ if len(key) > recoveryKeyMax {224+ sum := sha256.Sum256([]byte(key))225+ key = hex.EncodeToString(sum[:8]) + "-" + key[len(key)-(recoveryKeyMax-17):]226+ }227+ return key175228 }176229 177230 // recoveryFile is a recovery file found for a name.@@ -219,16 +272,13 @@ func prune(dir, key string) {
219272 }220273 221274 // offerRecovery asks, on the context line, whether to restore the222-// newest recovery file kept for name, when a served session opens it.275+// newest recovery file kept for name, when a session opens it.223276 func (m *Model) offerRecovery(name string) {224- if !m.root.Confined() {225- return226- }227- dir, err := recoveryDir(m.root.Dir(), false)277+ dir, key, err := m.recoveryPlace(name, false)228278 if err != nil {229279 return230280 }231- files := recoveries(dir, recoveryKey(name))281+ files := recoveries(dir, key)232282 if len(files) == 0 {233283 return234284 }@@ -39,7 +39,8 @@ func TestRecoveryKey(t *testing.T) {
3939 }4040 }4141 42-// Only changed books that hold something are kept, only when served,42+// Only changed books that hold something are kept, only when served (or43+// given a recovery directory, recovery tests in crash_test.go),4344 // and at most recoveryKeep per name, newest kept.4445 func TestRecoverKeepsFew(t *testing.T) {4546 local := newModel()@@ -123,7 +124,7 @@ func TestRecoveryOfferUntitled(t *testing.T) {
123124 124125 // Opening a file with kept changes offers them; restoring makes them125126 // unsaved changes to that file, and saving them removes the recovery126-// file. A local model never offers.127+// file. A local model without a recovery directory never offers.127128 func TestRecoveryRestore(t *testing.T) {128129 m, dir := servedModel(t)129130 writeSheet(t, filepath.Join(dir, "book.012"), "on disk")