feat(shell): run bash on a real PTY via creack/pty
The shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran without a tty. Programs that check isatty() (python, htop, less, vim-style REPLs) would drop echo, ignore line-editing, and leak literal 0x1b bytes into the program — Python's REPL reported 'invalid non-printable character U+001B' on input. Switch to github.com/creack/pty:StartWithSize with an 80x24 default and TERM=xterm-256color in cmd.Env. The PTY file replaces both the stdin and stdout pipes; sess.Stdin is the pty *os.File and the output capture goroutine reads from it directly. waitExit no longer needs to close sess.Stdin — the capture goroutine's defer handles it when the process dies and the pty returns EOF. - go.mod / go.sum: add creack/pty v1.1.24. - internal/shell/manager.go: rewrite Start to use pty.StartWithSize; captureOutput reads from the pty; waitExit simplified; race-clean exited map to avoid the cmd.ProcessState data race. - internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal (echo with echo + output interleaved proves PTY echo is on) and TestShellPTYTermEnv (TERM=xterm-256color propagates). E2E against the real bash: 'python3 -q' returns the >>> prompt, print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak. Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func startTestShell(t *testing.T) (*LocalManager, io.WriteCloser, Subscription) {
|
||||
t.Helper()
|
||||
mgr := NewManager("bash", []string{"-i"})
|
||||
root := t.TempDir()
|
||||
if err := mgr.Start("test-ws", root); err != nil {
|
||||
t.Fatalf("Start failed: %v", err)
|
||||
}
|
||||
|
||||
stdin, err := mgr.Stdin("test-ws")
|
||||
if err != nil {
|
||||
t.Fatalf("Stdin failed: %v", err)
|
||||
}
|
||||
|
||||
sub, err := mgr.Subscribe("test-ws")
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe failed: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
sub.Close()
|
||||
_ = mgr.Stop("test-ws")
|
||||
})
|
||||
|
||||
return mgr, stdin, sub
|
||||
}
|
||||
|
||||
func waitForMarker(t *testing.T, sub Subscription, stdin io.Writer, input, marker string) string {
|
||||
t.Helper()
|
||||
|
||||
if _, err := stdin.Write([]byte(input)); err != nil {
|
||||
t.Fatalf("failed to write to stdin: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var out bytes.Buffer
|
||||
for {
|
||||
select {
|
||||
case chunk, ok := <-sub.Output():
|
||||
if !ok {
|
||||
t.Fatalf("subscription closed before seeing %q; output so far:\n%s", marker, out.String())
|
||||
}
|
||||
out.Write(chunk)
|
||||
if strings.Contains(out.String(), marker) {
|
||||
return out.String()
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timed out waiting for %q; output so far:\n%s", marker, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellPTYIsRealTerminal(t *testing.T) {
|
||||
mgr, stdin, sub := startTestShell(t)
|
||||
_ = mgr
|
||||
|
||||
out := waitForMarker(t, sub, stdin, "echo HELLO_PTY_TEST\n", "HELLO_PTY_TEST")
|
||||
|
||||
if !strings.Contains(out, "echo HELLO_PTY_TEST") {
|
||||
t.Errorf("expected command echo in PTY output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "HELLO_PTY_TEST") {
|
||||
t.Errorf("expected command output in PTY output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellPTYTermEnv(t *testing.T) {
|
||||
_, stdin, sub := startTestShell(t)
|
||||
|
||||
out := waitForMarker(t, sub, stdin, "echo \"$TERM\"\n", "xterm-256color")
|
||||
if !strings.Contains(out, "xterm-256color") {
|
||||
t.Errorf("expected TERM=xterm-256color in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user