Feat: crash log (#7)

Merged #6
- crash log (db0e1be9a02f70927124f9f3563df5e9dee3a764)
- perf(watchdog): limit buffer size and optimize crash substring search (https://github.com/versenilvis/IRIS/pull/7/commits/7725434fedf8b32929dc0d71e1256fda13cfdfca)
- refactor(crash): use dynamic buffer for stack trace capture (https://github.com/versenilvis/IRIS/pull/7/commits/07809a4330a1236471e8dae0e83afd4323a4c83a)
This commit is contained in:
VERSE
2026-05-23 23:32:36 +07:00
committed by GitHub
parent d06526cf21
commit bf67e50ea6
6 changed files with 438 additions and 11 deletions
+150
View File
@@ -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)
}
+129 -2
View File
@@ -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()
}
+9
View File
@@ -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
+49 -8
View File
@@ -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 {