diff --git a/commands/core/utils.go b/commands/core/utils.go index c7856b4..6a1810d 100644 --- a/commands/core/utils.go +++ b/commands/core/utils.go @@ -8,7 +8,7 @@ import ( var DebugWriter io.Writer -func debugLog(format string, a ...interface{}) { +func debugLog(format string, a ...any) { if DebugWriter != nil { _, _ = fmt.Fprintf(DebugWriter, format+"\n", a...) } diff --git a/root/crash.go b/root/crash.go new file mode 100644 index 0000000..4894a29 --- /dev/null +++ b/root/crash.go @@ -0,0 +1,150 @@ +package root + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +// startRescueShell starts a fallback shell if the application crashes to keep the terminal open +func startRescueShell() { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + _ = syscall.Exec(shell, []string{shell}, os.Environ()) +} + +var ( + lastCrashFile string + lastCrashMu sync.Mutex +) + +// writeCrashLog writes the crash info and stack trace to a new log file +func WriteCrashLog(err any) { + home, errDir := os.UserHomeDir() + if errDir != nil { + return + } + dir := filepath.Join(home, ".iris", "crashes") + _ = os.MkdirAll(dir, 0755) + logFile := filepath.Join(dir, fmt.Sprintf("crash_%s.log", time.Now().Format("20060102_150405"))) + + lastCrashMu.Lock() + lastCrashFile = logFile + lastCrashMu.Unlock() + + f, errOpen := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if errOpen != nil { + return + } + defer f.Close() + + _, _ = fmt.Fprintf(f, "=== IRIS CRASH %s ===\n", time.Now().Format(time.RFC3339)) + _, _ = fmt.Fprintf(f, "version: %s\nos: %s/%s\n\n", Version, runtime.GOOS, runtime.GOARCH) + _, _ = fmt.Fprintf(f, "panic: %v\n\n", err) + + var n int + buf := make([]byte, 1024) + for { + n = runtime.Stack(buf, true) + if n < len(buf) { + break + } + buf = make([]byte, 2*len(buf)) + } + _, _ = f.Write(buf[:n]) + _, _ = fmt.Fprintln(f) +} + +// getLatestCrashLog returns the path to the newest crash log file +func getLatestCrashLog() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + dir := filepath.Join(home, ".iris", "crashes") + files, err := os.ReadDir(dir) + if err != nil || len(files) == 0 { + oldLog := filepath.Join(home, ".iris", "crash.log") + if _, err := os.Stat(oldLog); err == nil { + return oldLog + } + return "" + } + + var latest string + for _, f := range files { + if f.IsDir() { + continue + } + name := f.Name() + if strings.HasPrefix(name, "crash_") && strings.HasSuffix(name, ".log") { + if name > latest { + latest = name + } + } + } + if latest == "" { + oldLog := filepath.Join(home, ".iris", "crash.log") + if _, err := os.Stat(oldLog); err == nil { + return oldLog + } + return "" + } + return filepath.Join(dir, latest) +} + +// printCrashNotice prints the crash notice with absolute path to the log file +func printCrashNotice() { + lastCrashMu.Lock() + logFile := lastCrashFile + lastCrashMu.Unlock() + if logFile == "" { + logFile = getLatestCrashLog() + } + _, _ = fmt.Fprintf(os.Stderr, "\n\033[31m[IRIS] crashed, report saved to %s\033[0m\n", logFile) +} + +var ( + // crashCmd is the cobra command to manage crash logs + CrashCmd = &cobra.Command{ + Use: "crash-log", + Short: "manage iris crash logs", + Run: func(cmd *cobra.Command, args []string) { + home, err := os.UserHomeDir() + if err != nil { + cmd.Printf("failed to get home directory: %v\n", err) + return + } + + if ClearLog { + _ = os.RemoveAll(filepath.Join(home, ".iris", "crashes")) + _ = os.Remove(filepath.Join(home, ".iris", "crash.log")) + cmd.Println("crash log cleared") + return + } + + logFile := getLatestCrashLog() + if logFile == "" { + cmd.Println("no crash log found") + return + } + cmd.Println(logFile) + }, + } + // clearLog is the flag to clear the crash log + ClearLog bool +) + +func init() { + CrashCmd.Flags().BoolVar(&ClearLog, "clear", false, "clear the crash log") + rootCmd.AddCommand(CrashCmd) +} diff --git a/root/root.go b/root/root.go index 4b55de0..e5c3d42 100644 --- a/root/root.go +++ b/root/root.go @@ -1,8 +1,12 @@ package root import ( + "bytes" + "context" + "errors" "fmt" "os" + "os/exec" "strconv" "syscall" @@ -14,6 +18,7 @@ import ( _ "github.com/versenilvis/iris/commands/runner" _ "github.com/versenilvis/iris/commands/search" _ "github.com/versenilvis/iris/commands/view" + "golang.org/x/term" ) var ( @@ -23,6 +28,15 @@ var ( Long: `IRIS (a.k.a Intelligent Real-time Input Suggestion) is a shell auto-autocompletion tool. It works exactly like coding editor suggestion menu drop down.`, Run: func(cmd *cobra.Command, args []string) { + defer func() { + if r := recover(); r != nil { + WriteCrashLog(r) + restoreTerminal() + printCrashNotice() + startRescueShell() + os.Exit(2) + } + }() if pidStr := os.Getenv("IRIS_PID"); pidStr != "" { if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 { _ = syscall.Kill(pid, syscall.SIGUSR1) @@ -49,13 +63,117 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log") } -func debugLog(format string, a ...interface{}) { +func debugLog(format string, a ...any) { if debugLogger != nil { _, _ = fmt.Fprintf(debugLogger, format+"\n", a...) } } -func Execute() { +// runWatchdog spawns the watchdog parent process +func runWatchdog() { + exe, err := os.Executable() + if err != nil { + runOriginal() + return + } + + // save original terminal settings in parent process + watchdogOldState, errState := term.MakeRaw(int(os.Stdin.Fd())) + if errState == nil { + _ = term.Restore(int(os.Stdin.Fd()), watchdogOldState) + } + + r, w, err := os.Pipe() + if err != nil { + runOriginal() + return + } + + cmd := exec.CommandContext(context.Background(), exe, os.Args[1:]...) + cmd.Env = append(os.Environ(), "IRIS_IS_CHILD=true") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = w + + err = cmd.Start() + if err != nil { + runOriginal() + return + } + + _ = w.Close() + + // copy child stderr to both our buffer and the real stderr, filtering out panics + var stderrBuf bytes.Buffer + origStderr := os.Stderr + tempBuf := make([]byte, 1024) + suppress := false + for { + n, errRead := r.Read(tempBuf) + if n > 0 { + _, _ = stderrBuf.Write(tempBuf[:n]) + if stderrBuf.Len() > 64*1024 { + // discard oldest bytes to avoid memory leak + over := stderrBuf.Len() - 64*1024 + _ = stderrBuf.Next(over) + } + if !suppress { + currentContent := stderrBuf.Bytes() + searchStart := 0 + if len(currentContent) > n+12 { + searchStart = len(currentContent) - (n + 12) + } + searchSlice := currentContent[searchStart:] + idxPanic := bytes.Index(searchSlice, []byte("panic:")) + idxFatal := bytes.Index(searchSlice, []byte("fatal error:")) + triggerIdx := -1 + if idxPanic != -1 { + triggerIdx = searchStart + idxPanic + } else if idxFatal != -1 { + triggerIdx = searchStart + idxFatal + } + + if triggerIdx != -1 { + suppress = true + printedLen := len(currentContent) - n + if triggerIdx > printedLen { + _, _ = origStderr.Write(currentContent[printedLen:triggerIdx]) + } + } else { + _, _ = origStderr.Write(tempBuf[:n]) + } + } + } + if errRead != nil { + break + } + } + + // check if child exited abnormally or crashed + errWait := cmd.Wait() + if errWait != nil { + content := stderrBuf.Bytes() + if bytes.Contains(content, []byte("panic:")) || bytes.Contains(content, []byte("fatal error:")) { + WriteCrashLog(string(content)) + // restore terminal state if watchdog saved it + if watchdogOldState != nil { + _ = term.Restore(int(os.Stdin.Fd()), watchdogOldState) + } + printCrashNotice() + startRescueShell() + os.Exit(2) + } + + var exitErr *exec.ExitError + if errors.As(errWait, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + } +} + +// runOriginal runs the normal command execution +func runOriginal() { if os.Getenv("IRIS_RELOADED") == "true" { fmt.Printf("\r\033[K\033[35m[IRIS] reloading...\033[0m\n") _ = os.Unsetenv("IRIS_RELOADED") @@ -66,3 +184,12 @@ func Execute() { os.Exit(1) } } + +func Execute() { + if os.Getenv("IRIS_IS_CHILD") != "true" { + runWatchdog() + return + } + + runOriginal() +} diff --git a/root/update.go b/root/update.go index a6aeec9..52bd32f 100644 --- a/root/update.go +++ b/root/update.go @@ -162,6 +162,15 @@ func startBackgroundUpdateCheck() chan updateResult { ch := make(chan updateResult, 1) go func() { + defer func() { + if r := recover(); r != nil { + WriteCrashLog(r) + restoreTerminal() + printCrashNotice() + startRescueShell() + os.Exit(2) + } + }() defer close(ch) // debug override: skip network entirely, resolve immediately diff --git a/root/wrapper.go b/root/wrapper.go index f31b01b..cfc797f 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -66,6 +66,21 @@ func saveMode(mode string) { } } +var ( + oldState *term.State + oldStateMu sync.Mutex +) + +// restoreTerminal restores the terminal state if needed +func restoreTerminal() { + oldStateMu.Lock() + defer oldStateMu.Unlock() + if oldState != nil { + _ = term.Restore(int(os.Stdin.Fd()), oldState) + oldState = nil + } +} + // runWrapper sets up the pty environment, launches the shell, // and manages the main input loop to provide real-time suggestions // it handles raw terminal mode to intercept keystrokes and @@ -107,15 +122,25 @@ func runWrapper() { core.ShellPID = c.Process.Pid // put terminal in raw mode to intercept every keystroke - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - panic(err) + var errMakeRaw error + oldState, errMakeRaw = term.MakeRaw(int(os.Stdin.Fd())) + if errMakeRaw != nil { + panic(errMakeRaw) } - defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() + defer restoreTerminal() sigCh := make(chan os.Signal, 2) signal.Notify(sigCh, syscall.SIGWINCH, syscall.SIGUSR1) go func() { + defer func() { + if r := recover(); r != nil { + WriteCrashLog(r) + restoreTerminal() + printCrashNotice() + startRescueShell() + os.Exit(2) + } + }() for s := range sigCh { switch s { case syscall.SIGWINCH: @@ -139,9 +164,7 @@ func runWrapper() { _ = ptmx.Close() } - if oldState != nil { - _ = term.Restore(int(os.Stdin.Fd()), oldState) - } + restoreTerminal() _ = syscall.Exec(exe, os.Args, os.Environ()) } } @@ -155,12 +178,21 @@ func runWrapper() { // bridge pty output to actual stdout go func() { + defer func() { + if r := recover(); r != nil { + WriteCrashLog(r) + restoreTerminal() + printCrashNotice() + startRescueShell() + os.Exit(2) + } + }() buf := make([]byte, 4096) for { n, err := ptmx.Read(buf) if err != nil { if err == io.EOF { - _ = term.Restore(int(os.Stdin.Fd()), oldState) + restoreTerminal() os.Exit(0) } continue @@ -187,6 +219,15 @@ func runWrapper() { // listen for suggestion requests from shell scripts via the ipc pipe go func() { + defer func() { + if r := recover(); r != nil { + WriteCrashLog(r) + restoreTerminal() + printCrashNotice() + startRescueShell() + os.Exit(2) + } + }() scanner := bufio.NewScanner(r) scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { if atEOF && len(data) == 0 { diff --git a/tests/crash_test.go b/tests/crash_test.go new file mode 100644 index 0000000..3c3e220 --- /dev/null +++ b/tests/crash_test.go @@ -0,0 +1,100 @@ +package tests + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/versenilvis/iris/root" +) + +func TestWriteCrashLog(t *testing.T) { + + tmpDir, err := os.MkdirTemp("", "iris-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + origHome := os.Getenv("HOME") + defer func() { + _ = os.Setenv("HOME", origHome) + }() + _ = os.Setenv("HOME", tmpDir) + + testErr := "test panic message" + root.WriteCrashLog(testErr) + + dir := filepath.Join(tmpDir, ".iris", "crashes") + files, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("failed to read crashes dir: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected exactly 1 crash log file, got %d", len(files)) + } + + logPath := filepath.Join(dir, files[0].Name()) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("failed to read crash log: %v", err) + } + + content := string(data) + if !strings.Contains(content, "=== IRIS CRASH ") { + t.Errorf("expected header in log, got: %s", content) + } + if !strings.Contains(content, "panic: test panic message") { + t.Errorf("expected panic message in log, got: %s", content) + } + if !strings.Contains(content, "version:") { + t.Errorf("expected version in log, got: %s", content) + } +} + +func TestCrashLogCommand(t *testing.T) { + + tmpDir, err := os.MkdirTemp("", "iris-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + origHome := os.Getenv("HOME") + defer func() { + _ = os.Setenv("HOME", origHome) + }() + _ = os.Setenv("HOME", tmpDir) + + var buf bytes.Buffer + root.CrashCmd.SetOut(&buf) + root.CrashCmd.SetArgs([]string{}) + root.ClearLog = false + + root.CrashCmd.Run(root.CrashCmd, []string{}) + if !strings.Contains(buf.String(), "no crash log found") { + t.Errorf("expected 'no crash log found', got: %q", buf.String()) + } + + root.WriteCrashLog("mock error") + buf.Reset() + root.CrashCmd.Run(root.CrashCmd, []string{}) + // expect the output to contain crash_ and .log, which is the path of the new crash log file + if !strings.Contains(buf.String(), "crash_") || !strings.Contains(buf.String(), ".log") { + t.Errorf("expected crash log path, got: %q", buf.String()) + } + + buf.Reset() + root.ClearLog = true + root.CrashCmd.Run(root.CrashCmd, []string{}) + if !strings.Contains(buf.String(), "crash log cleared") { + t.Errorf("expected 'crash log cleared', got: %q", buf.String()) + } + + dir := filepath.Join(tmpDir, ".iris", "crashes") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("expected crashes directory to be deleted, but it exists") + } +}