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:
+6
-2
@@ -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
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user