### Summary of Changes Fixes **#53** (*Fish shell integration panics with `inappropriate ioctl for device`*). #### 1. Fixed Non-TTY Stdin (`panic: inappropriate ioctl for device`) — Fixes #53 - **Root Cause**: When executing `iris init fish | source` (or sourcing shell configs), `os.Stdin` (FD 0) is connected to a pipe instead of a TTY. Calling `term.MakeRaw()` on a pipe returns `ENOTTY`. - **Fix**: Guarded `term.MakeRaw()` with `term.IsTerminal()` checks across `root/wrapper.go` and `root/root.go`. Added fallback to open and use `/dev/tty` when `os.Stdin` is not a terminal device. - **Fish IPC Hooks**: Added `fish_preexec` and `fish_postexec` event handlers to `iris init fish` to send `IRIS_CMD_START` / `IRIS_CMD_STOP` IPC signals over `$IRIS_FD`. #### 2. Fixed Shell Setup Order (Duplicate `fastfetch` / startup executions) — Fixes #53 - **Root Cause**: `iris setup` appended the init script to the bottom of shell configs (`config.fish`, `.zshrc`, `.bashrc`), causing pre-existing interactive startup commands to run twice. - **Fix**: Updated `setupCmd` in `root/init.go` to **prepend** `# Iris Autocomplete` to the top of shell configuration files. #### 3. Fixed Terminal Exit Loop Handling — Relates to #50 - **Root Cause**: When typing `exit` in the wrapped shell, Linux PTY returns `syscall.EIO` (`Input/output error`) on `ptmx.Read()` rather than `io.EOF`. `runWrapper()` was ignoring non-EOF errors and looping indefinitely without restoring the terminal or calling `os.Exit(0)`. - **Fix**: Updated the `ptmx.Read` error handler in `root/wrapper.go` to handle `syscall.EIO` and exit cleanly. --- ### Issues Addressed - Closes #53 - Relates to #50, #55 --- ### Verification Tested - [x] Verified `iris setup fish` / `iris init fish | source` initializes without `ioctl` panic. - [x] Verified typing `exit` in Fish, Zsh, and Bash terminates IRIS cleanly and restores standard terminal state. - [x] Ran `go test ./...` — all tests pass. --------- Co-authored-by: verse91 <versedev.store@proton.me> Co-authored-by: VERSE <sunnyone0901@gmail.com>
217 lines
5.4 KiB
Go
217 lines
5.4 KiB
Go
package root
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/spf13/cobra"
|
|
_ "github.com/versenilvis/iris/commands"
|
|
"github.com/versenilvis/iris/internal/config"
|
|
"github.com/versenilvis/iris/internal/logger"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
var (
|
|
rootCmd = &cobra.Command{
|
|
Use: "iris",
|
|
Short: "IRIS is an awesome cli auto-completion tool",
|
|
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 {
|
|
if logDir, err := config.CachePath(); err == nil {
|
|
argsFile := filepath.Join(logDir, "reload-args")
|
|
_ = os.WriteFile(argsFile, []byte(strings.Join(os.Args[1:], "\n")), 0600)
|
|
}
|
|
_ = syscall.Kill(pid, syscall.SIGUSR1)
|
|
fmt.Println("\r\033[K\033[36m[IRIS] Sent reload signal to parent session.\033[0m")
|
|
return
|
|
}
|
|
}
|
|
runWrapper()
|
|
},
|
|
}
|
|
shellFlag string
|
|
debugMode bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.PersistentFlags().StringVarP(&shellFlag, "shell", "s", "", "shell to use (bash, zsh, fish)")
|
|
rootCmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "enable debug logging to iris.log")
|
|
|
|
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
|
if shellFlag != "" {
|
|
config.Get().Core.Shell = shellFlag
|
|
}
|
|
logDir, err := config.CachePath()
|
|
if err == nil {
|
|
logger.Init(filepath.Join(logDir, "iris.log"), debugMode || config.Get().Core.Debug)
|
|
logger.Infof("IRIS session started: os=%s, arch=%s, go=%s, pid=%d", runtime.GOOS, runtime.GOARCH, runtime.Version(), os.Getpid())
|
|
cfg := config.Get()
|
|
logger.Debugf("IRIS loaded config: shell=%q, mode=%q, ghost-text=%v, max-suggestions=%d", cfg.Core.Shell, cfg.Core.Mode, cfg.UI.GhostText, cfg.UI.MaxSuggestions)
|
|
}
|
|
}
|
|
}
|
|
|
|
// runWatchdog spawns the watchdog parent process
|
|
func runWatchdog() {
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
runOriginal()
|
|
return
|
|
}
|
|
|
|
cmdStdin := os.Stdin
|
|
if !term.IsTerminal(int(cmdStdin.Fd())) {
|
|
if tty, ttyErr := os.OpenFile("/dev/tty", os.O_RDWR, 0); ttyErr == nil {
|
|
cmdStdin = tty
|
|
}
|
|
}
|
|
|
|
// save original terminal settings in parent process if Stdin is a terminal
|
|
var watchdogOldState *term.State
|
|
if term.IsTerminal(int(cmdStdin.Fd())) {
|
|
var errState error
|
|
watchdogOldState, errState = term.MakeRaw(int(cmdStdin.Fd()))
|
|
if errState == nil {
|
|
_ = term.Restore(int(cmdStdin.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 = cmdStdin
|
|
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(cmdStdin.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")
|
|
}
|
|
|
|
if err := rootCmd.Execute(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func Execute() {
|
|
_ = config.MigrateFromLegacyJSON()
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[IRIS] config error: %v\n", err)
|
|
}
|
|
config.Init(cfg)
|
|
|
|
if os.Getenv("IRIS_IS_CHILD") != "true" {
|
|
runWatchdog()
|
|
return
|
|
}
|
|
|
|
runOriginal()
|
|
}
|