| 1 | // chat_tui_shutdown.go — the one exit every quit gesture and signal funnels into. |
| 2 | package cli |
| 3 | |
| 4 | import ( |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "sync/atomic" |
| 8 | |
| 9 | tea "charm.land/bubbletea/v2" |
| 10 | |
| 11 | "reasonix/internal/i18n" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | tuiShutdownPending uint32 = iota |
| 16 | tuiShutdownCompleted |
| 17 | tuiShutdownFallbackClaimed |
| 18 | ) |
| 19 | |
| 20 | // tuiShutdownCompletion arbitrates the boundary between a completed graceful |
| 21 | // exit and the watchdog fallback. Exactly one side wins, so a timer callback |
| 22 | // cannot turn a snapshot that already completed into ErrProgramKilled. |
| 23 | type tuiShutdownCompletion struct { |
| 24 | state atomic.Uint32 |
| 25 | done chan struct{} |
| 26 | } |
| 27 | |
| 28 | func newTUIShutdownCompletion() *tuiShutdownCompletion { |
| 29 | return &tuiShutdownCompletion{done: make(chan struct{})} |
| 30 | } |
| 31 | |
| 32 | func (c *tuiShutdownCompletion) complete() { |
| 33 | if c != nil && c.state.CompareAndSwap(tuiShutdownPending, tuiShutdownCompleted) { |
| 34 | if c.done != nil { |
| 35 | close(c.done) |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func (c *tuiShutdownCompletion) claimFallback() bool { |
| 41 | return c != nil && c.state.CompareAndSwap(tuiShutdownPending, tuiShutdownFallbackClaimed) |
| 42 | } |
| 43 | |
| 44 | // shutdownAndQuit persists what the controller holds beyond the last snapshot |
| 45 | // and leaves. The shutdown variant writes a recovery branch when another |
| 46 | // process keeps the active session's compatibility lock for the bounded wait. |
| 47 | func (m chatTUI) shutdownAndQuit(msg tuiShutdownMsg) (tea.Model, tea.Cmd) { |
| 48 | if !msg.userInitiated && m.takeover != nil && m.takeover.Reclaiming() { |
| 49 | // The manager goroutine owns the in-flight reclaim's final snapshot and |
| 50 | // its return, so the TUI must not race it with a second snapshot. The |
| 51 | // request is deferred, not dropped: completeSessionReclaim honors it. |
| 52 | if msg.completion != nil { |
| 53 | msg.completion.complete() |
| 54 | } |
| 55 | m.shutdownAfterReclaim = true |
| 56 | return m, nil |
| 57 | } |
| 58 | if msg.completion != nil { |
| 59 | defer msg.completion.complete() |
| 60 | } |
| 61 | // Snapshot only while this process still writes the session. A reclaimed |
| 62 | // or returned session belongs to the remote side again, and its runtime |
| 63 | // binding is already released. |
| 64 | if m.ctrl != nil && !m.sessionDetached() { |
| 65 | m.shutdownErr = m.ctrl.SnapshotForShutdown() |
| 66 | m.followSessionLease() |
| 67 | } |
| 68 | return m, tea.Quit |
| 69 | } |
| 70 | |
| 71 | // reportShutdownFailure runs after Bubble Tea restores the terminal, when a |
| 72 | // final-save failure can be printed without corrupting the alt screen. |
| 73 | func reportShutdownFailure(err error) { |
| 74 | if err != nil { |
| 75 | fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err) |
| 76 | } |
| 77 | } |
| 78 |