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)
This commit is contained in:
VERSE
2026-08-13 10:54:12 +07:00
committed by GitHub
parent 0f25a4c26b
commit 45d182f072
4 changed files with 131 additions and 2 deletions
+6 -2
View File
@@ -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 unset IRIS_PID IRIS_IS_CHILD IRIS_FD IRIS_TTY
fi 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" export IRIS_ACTIVE_SHELL="zsh"
exec iris exec iris
fi 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 unset IRIS_PID IRIS_IS_CHILD IRIS_FD IRIS_TTY
fi 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" export IRIS_ACTIVE_SHELL="bash"
exec iris exec iris
fi fi
+59
View File
@@ -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:])
}
})
}
}
+15
View File
@@ -107,6 +107,17 @@ func writeStdout(data []byte) {
_, _ = os.Stdout.Write(data) _, _ = 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 // restoreTerminal restores the terminal state if needed
func restoreTerminal() { func restoreTerminal() {
oldStateMu.Lock() oldStateMu.Lock()
@@ -129,6 +140,8 @@ func syncProcessCWD(cwd string) {
// it handles raw terminal mode to intercept keystrokes and // it handles raw terminal mode to intercept keystrokes and
// coordinates between the shell process and the suggestion overlay // coordinates between the shell process and the suggestion overlay
func runWrapper() { func runWrapper() {
restoreStdoutTTY()
var naiveBuffer string var naiveBuffer string
var lastSubmittedCommand string var lastSubmittedCommand string
cursorOffset := 0 cursorOffset := 0
@@ -1057,6 +1070,7 @@ func runWrapper() {
} }
continue continue
} }
rawSeq := append([]byte(nil), inputSlice[i:i+navConsumed]...)
i += navConsumed - 1 i += navConsumed - 1
intercepted = true intercepted = true
bufferMu.Lock() bufferMu.Lock()
@@ -1095,6 +1109,7 @@ func runWrapper() {
userNavigated.Store(false) userNavigated.Store(false)
} }
bufferMu.Unlock() bufferMu.Unlock()
_, _ = ptmx.Write(rawSeq)
isLeftRightArrow = true isLeftRightArrow = true
} }
} }
+51
View File
@@ -4,6 +4,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"golang.org/x/term"
) )
func wantDir(t *testing.T, path string) string { func wantDir(t *testing.T, path string) string {
@@ -24,6 +26,55 @@ func currentDir(t *testing.T) string {
return wantDir(t, cwd) 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) { func TestSyncProcessCWDFollowsShell(t *testing.T) {
shellDir := t.TempDir() shellDir := t.TempDir()
t.Chdir(t.TempDir()) t.Chdir(t.TempDir())