feat: add double Ctrl+C hard-kill and split TUI into tui.go

First signal cancels the main context; second releases the terminal
and exits 130, unblocking shutdown when captcha polls or user scripts
ignore context. Also moves the bubbletea models and streamProgress
out of sarin.go into a new tui.go.
This commit is contained in:
2026-04-17 16:03:03 +04:00
parent e62fd33f9c
commit 2eebac68c9
4 changed files with 365 additions and 296 deletions
+55
View File
@@ -0,0 +1,55 @@
package sarin
import (
"fmt"
"os"
"sync"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
)
const forceExitCode = 130
// StopController coordinates a two-stage shutdown.
//
// The first Stop call cancels the supplied context so workers and the job
// loop can drain. The second Stop call restores the terminal (if a bubbletea
// program has been attached) and calls os.Exit(forceExitCode), bypassing any
// in-flight captcha polls, Lua/JS scripts, or HTTP requests that would
// otherwise keep the process alive.
type StopController struct {
count atomic.Int32
cancel func()
mu sync.Mutex
program *tea.Program
}
func NewStopController(cancel func()) *StopController {
return &StopController{cancel: cancel}
}
// AttachProgram registers the active bubbletea program so the terminal state
// can be restored before os.Exit on the forced shutdown path. Pass nil to
// detach once the program has finished.
func (s *StopController) AttachProgram(program *tea.Program) {
s.mu.Lock()
s.program = program
s.mu.Unlock()
}
func (s *StopController) Stop() {
switch s.count.Add(1) {
case 1:
s.cancel()
case 2:
s.mu.Lock()
p := s.program
s.mu.Unlock()
if p != nil {
_ = p.ReleaseTerminal()
}
fmt.Fprintln(os.Stderr, "killing...")
os.Exit(forceExitCode)
}
}