From 45d182f07287e4ab1a62169c6b194b4ca276bedc Mon Sep 17 00:00:00 2001 From: VERSE Date: Thu, 13 Aug 2026 10:54:12 +0700 Subject: [PATCH] fix: terminal hangs, right arrow blocking, and runaway iris processes (#142) closes #137 - right arrow consumed the escape sequence without forwarding it, so the cursor couldn't move back right after moving left (60149a2) - p10k's instant prompt redirects stdout to a temp file that exec iris inherits, sending the overlay there instead of the terminal and looking like a dead keyboard. Reopens /dev/tty when stdout isn't a terminal (7ebbb0c) - zsh/bash autostart lacked the interactivity check fish already had, so agentic tools sourcing your rc file spawned a rogue iris that seized /dev/tty (5fe58be) --- root/init.go | 8 ++++-- root/init_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++ root/wrapper.go | 15 +++++++++++ root/wrapper_test.go | 51 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 root/init_test.go diff --git a/root/init.go b/root/init.go index fb4d4d4..3916d30 100644 --- a/root/init.go +++ b/root/init.go @@ -31,7 +31,9 @@ if [ -n "$IRIS_PID" ] && [ "$IRIS_PID" != "$PPID" ] && [ "${TTY:-$(tty 2>/dev/nu unset IRIS_PID IRIS_IS_CHILD IRIS_FD IRIS_TTY fi -if [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then +# a non-interactive shell (tool runners sourcing rc files, scripts) has no +# prompt to complete, and exec'ing here would seize the tty from the real iris +if [[ -o interactive ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then export IRIS_ACTIVE_SHELL="zsh" exec iris fi @@ -74,7 +76,9 @@ if [ -n "$IRIS_PID" ] && [ "$IRIS_PID" != "$PPID" ] && [ "$(tty 2>/dev/null)" != unset IRIS_PID IRIS_IS_CHILD IRIS_FD IRIS_TTY fi -if [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then +# a non-interactive shell (tool runners sourcing rc files, scripts) has no +# prompt to complete, and exec'ing here would seize the tty from the real iris +if [[ $- == *i* ]] && [ -t 0 ] && [ -z "$IRIS_PID" ] && [ -z "$IRIS_RESCUE" ]; then export IRIS_ACTIVE_SHELL="bash" exec iris fi diff --git a/root/init_test.go b/root/init_test.go new file mode 100644 index 0000000..4cbb6e8 --- /dev/null +++ b/root/init_test.go @@ -0,0 +1,59 @@ +package root + +import ( + "io" + "os" + "strings" + "testing" +) + +func captureInitScript(t *testing.T, shell string) string { + t.Helper() + + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + original := os.Stdout + os.Stdout = w + initCmd.Run(initCmd, []string{shell}) + _ = w.Close() + os.Stdout = original + + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(out) +} + +// a tool runner that sources the rc file in a non-interactive shell must not +// exec a second iris, which would seize the tty from the one already running +func TestInitAutostartRequiresInteractiveShell(t *testing.T) { + guards := map[string]string{ + "zsh": "[[ -o interactive ]]", + "bash": "[[ $- == *i* ]]", + "fish": "status is-interactive", + } + + for shell, guard := range guards { + t.Run(shell, func(t *testing.T) { + script := captureInitScript(t, shell) + + head, _, found := strings.Cut(script, "exec iris") + if !found { + t.Fatalf("%s init script has no autostart", shell) + } + + cond := strings.LastIndex(head, "\nif ") + if cond < 0 { + t.Fatalf("%s autostart is not inside an if", shell) + } + + if !strings.Contains(head[cond:], guard) { + t.Fatalf("%s autostart is not guarded by %q:\n%s", shell, guard, head[cond:]) + } + }) + } +} diff --git a/root/wrapper.go b/root/wrapper.go index 873fc2a..b0bee66 100644 --- a/root/wrapper.go +++ b/root/wrapper.go @@ -107,6 +107,17 @@ func writeStdout(data []byte) { _, _ = os.Stdout.Write(data) } +func restoreStdoutTTY() { + if term.IsTerminal(int(os.Stdout.Fd())) { + return + } + tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + return + } + os.Stdout = tty +} + // restoreTerminal restores the terminal state if needed func restoreTerminal() { oldStateMu.Lock() @@ -129,6 +140,8 @@ func syncProcessCWD(cwd string) { // it handles raw terminal mode to intercept keystrokes and // coordinates between the shell process and the suggestion overlay func runWrapper() { + restoreStdoutTTY() + var naiveBuffer string var lastSubmittedCommand string cursorOffset := 0 @@ -1057,6 +1070,7 @@ func runWrapper() { } continue } + rawSeq := append([]byte(nil), inputSlice[i:i+navConsumed]...) i += navConsumed - 1 intercepted = true bufferMu.Lock() @@ -1095,6 +1109,7 @@ func runWrapper() { userNavigated.Store(false) } bufferMu.Unlock() + _, _ = ptmx.Write(rawSeq) isLeftRightArrow = true } } diff --git a/root/wrapper_test.go b/root/wrapper_test.go index 01bffa4..a8788bb 100644 --- a/root/wrapper_test.go +++ b/root/wrapper_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "golang.org/x/term" ) func wantDir(t *testing.T, path string) string { @@ -24,6 +26,55 @@ func currentDir(t *testing.T) string { return wantDir(t, cwd) } +func TestRestoreStdoutTTYRedirectsFileStdoutToTerminal(t *testing.T) { + tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + t.Skip("no controlling terminal available") + } + _ = tty.Close() + + // stand in for the temp file powerlevel10k's instant prompt leaves on stdout + redirected, err := os.CreateTemp(t.TempDir(), "instant-prompt-output") + if err != nil { + t.Fatal(err) + } + defer func() { _ = redirected.Close() }() + + original := os.Stdout + os.Stdout = redirected + t.Cleanup(func() { os.Stdout = original }) + + restoreStdoutTTY() + + if os.Stdout == redirected { + t.Fatal("restoreStdoutTTY left stdout pointing at the redirected file") + } + if !term.IsTerminal(int(os.Stdout.Fd())) { + t.Fatal("restoreStdoutTTY did not leave stdout on a terminal") + } + _ = os.Stdout.Close() +} + +func TestRestoreStdoutTTYKeepsExistingTerminal(t *testing.T) { + tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + t.Skip("no controlling terminal available") + } + + original := os.Stdout + os.Stdout = tty + t.Cleanup(func() { + os.Stdout = original + _ = tty.Close() + }) + + restoreStdoutTTY() + + if os.Stdout != tty { + t.Fatal("restoreStdoutTTY reopened stdout that was already a terminal") + } +} + func TestSyncProcessCWDFollowsShell(t *testing.T) { shellDir := t.TempDir() t.Chdir(t.TempDir())