From fc7a5b9d876b991f13e14d5d8db7368e8ddbb057 Mon Sep 17 00:00:00 2001 From: Muyue Date: Mon, 27 Apr 2026 11:56:40 +0200 Subject: [PATCH] fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal tab was unusable on Windows: creack/pty has no native Windows ConPTY support, so pty.Start() returned "operating system not supported" and the WebSocket closed immediately on any tab click — even though the menu detection (wsl --list --quiet, pwsh, cmd) worked. Introduce a termSession interface with two implementations selected at runtime: - ptySession (unix): unchanged behaviour, real PTY via creack/pty, resize works, vim/top behave normally. - pipeSession (windows): plain stdin + merged stdout/stderr pipes, forwarded to the WebSocket. Resize is a no-op (no SIGWINCH without a TTY), so full-screen TUIs misbehave in this mode — but launching wsl.exe, pwsh, or cmd works for line-based interaction, which is what the menu shortcuts target. handleTerminalWS now goes through startTermSession(cmd); the unix path is unchanged, the windows fallback kicks in only when pty.Start would have failed. Bump v0.7.0 → v0.7.1; CHANGELOG entry added. --- CHANGELOG.md | 10 ++ internal/api/terminal.go | 35 +++--- internal/api/terminal_session.go | 198 +++++++++++++++++++++++++++++++ internal/version/version.go | 2 +- 4 files changed, 224 insertions(+), 21 deletions(-) create mode 100644 internal/api/terminal_session.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b1799..d92c906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## v0.7.1 + +### Fix + +- **fix(terminal/windows): "unsupported" / connection closed** — `creack/pty` n'a pas de support Windows natif et `pty.Start()` retourne immédiatement une erreur ("operating system not supported"), fermant le WebSocket avant même la bannière. L'utilisateur voyait le menu des terminaux peuplé (détection OK : `wsl --list --quiet` fonctionne) mais chaque clic se soldait par "unsupported" ou une connexion fermée. +- Introduction de l'abstraction `termSession` (`internal/api/terminal_session.go`) avec deux implémentations sélectionnées au runtime : + - **`ptySession`** (Linux / macOS / BSDs) : conserve le comportement existant (TTY complet via `creack/pty`, resize, apps interactives type vim/top). + - **`pipeSession`** (Windows) : pipes natifs `stdin` + `stdout` + `stderr` mergés, lus en goroutines, forwardés au WebSocket. Suffisant pour `wsl.exe`, `pwsh`, `cmd` en mode ligne — la plupart des cas d'usage (lancer une commande, voir la sortie, taper la suivante). Resize est un no-op (pas de SIGWINCH sans TTY) ; les TUIs en plein écran ne fonctionnent pas dans ce mode. +- Refactor minimal de `handleTerminalWS` : utilise `startTermSession(cmd)` au lieu de `pty.Start(cmd)` direct ; même chemin code pour les deux OS. + ## v0.7.0 ### Changes since v0.4.0 diff --git a/internal/api/terminal.go b/internal/api/terminal.go index 566eb64..445a657 100644 --- a/internal/api/terminal.go +++ b/internal/api/terminal.go @@ -13,7 +13,6 @@ import ( "sync" "time" - "github.com/creack/pty/v2" "github.com/gorilla/websocket" "github.com/muyue/muyue/internal/config" ) @@ -154,7 +153,7 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { } cmd.Env = append(cmd.Env, "TERM=xterm-256color") - ptmx, err := pty.Start(cmd) + session, err := startTermSession(cmd) if err != nil { conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()}) return @@ -163,11 +162,8 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { var once sync.Once cleanup := func() { once.Do(func() { - ptmx.Close() - if cmd.Process != nil { - cmd.Process.Kill() - cmd.Wait() - } + session.Close() + session.Wait() }) } defer cleanup() @@ -175,15 +171,17 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { go func() { buf := make([]byte, 4096) for { - n, err := ptmx.Read(buf) - if err != nil { - cleanup() - return + n, err := session.Read(buf) + if n > 0 { + if err := conn.WriteJSON(wsMessage{ + Type: "output", + Data: string(buf[:n]), + }); err != nil { + cleanup() + return + } } - if err := conn.WriteJSON(wsMessage{ - Type: "output", - Data: string(buf[:n]), - }); err != nil { + if err != nil { cleanup() return } @@ -207,16 +205,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) { switch msg.Type { case "input": - if _, err := ptmx.Write([]byte(msg.Data)); err != nil { + if _, err := session.Write([]byte(msg.Data)); err != nil { cleanup() return } case "resize": if msg.Rows > 0 && msg.Cols > 0 { - pty.Setsize(ptmx, &pty.Winsize{ - Rows: msg.Rows, - Cols: msg.Cols, - }) + session.Resize(msg.Rows, msg.Cols) } } } diff --git a/internal/api/terminal_session.go b/internal/api/terminal_session.go new file mode 100644 index 0000000..0dcdc68 --- /dev/null +++ b/internal/api/terminal_session.go @@ -0,0 +1,198 @@ +package api + +// Cross-platform terminal session abstraction. +// +// On Linux / macOS we have a real PTY via creack/pty: full TTY semantics, +// resize support, interactive apps (vim, top…) work. On Windows the same +// package returns "operating system not supported" at pty.Start time, so we +// fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't +// give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`, +// and most CLI tools emit usable line-buffered output, which is what the +// user actually clicks for. + +import ( + "io" + "os" + "os/exec" + "runtime" + "sync" + + "github.com/creack/pty/v2" +) + +// termSession is the read/write/resize/close surface used by handleTerminalWS. +type termSession interface { + Read([]byte) (int, error) + Write([]byte) (int, error) + Resize(rows, cols uint16) error + Close() error + Wait() error + Pid() int +} + +// startTermSession tries a real PTY first; on Windows or any pty.Start failure +// it falls back to a pipe-based session. +func startTermSession(cmd *exec.Cmd) (termSession, error) { + if runtime.GOOS != "windows" { + ptmx, err := pty.Start(cmd) + if err == nil { + return &ptySession{ptmx: ptmx, cmd: cmd}, nil + } + // On unix, a pty.Start error is fatal — pipes won't help interactive + // shells without a TTY, and the unix build is the supported path. + return nil, err + } + return startPipeSession(cmd) +} + +// ptySession wraps creack/pty's *os.File-backed PTY. +type ptySession struct { + ptmx *os.File + cmd *exec.Cmd +} + +func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) } +func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } +func (s *ptySession) Resize(rows, cols uint16) error { + return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols}) +} +func (s *ptySession) Close() error { + err := s.ptmx.Close() + if s.cmd.Process != nil { + s.cmd.Process.Kill() + } + return err +} +func (s *ptySession) Wait() error { + if s.cmd.Process == nil { + return nil + } + return s.cmd.Wait() +} +func (s *ptySession) Pid() int { + if s.cmd.Process == nil { + return 0 + } + return s.cmd.Process.Pid +} + +// pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe, +// running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to). +type pipeSession struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + mu sync.Mutex + merged chan []byte + closed bool + closeCh chan struct{} +} + +func startPipeSession(cmd *exec.Cmd) (termSession, error) { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + return nil, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + stdin.Close() + stdout.Close() + return nil, err + } + if err := cmd.Start(); err != nil { + stdin.Close() + stdout.Close() + stderr.Close() + return nil, err + } + s := &pipeSession{ + cmd: cmd, + stdin: stdin, + stdout: stdout, + stderr: stderr, + merged: make(chan []byte, 32), + closeCh: make(chan struct{}), + } + go s.pump(stdout) + go s.pump(stderr) + return s, nil +} + +func (s *pipeSession) pump(r io.ReadCloser) { + buf := make([]byte, 4096) + for { + n, err := r.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + select { + case s.merged <- chunk: + case <-s.closeCh: + return + } + } + if err != nil { + return + } + } +} + +func (s *pipeSession) Read(p []byte) (int, error) { + select { + case chunk, ok := <-s.merged: + if !ok { + return 0, io.EOF + } + n := copy(p, chunk) + return n, nil + case <-s.closeCh: + return 0, io.EOF + } +} + +func (s *pipeSession) Write(p []byte) (int, error) { + return s.stdin.Write(p) +} + +func (s *pipeSession) Resize(rows, cols uint16) error { + // No real TTY → resize is a no-op; the child won't get SIGWINCH. + return nil +} + +func (s *pipeSession) Close() error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil + } + s.closed = true + close(s.closeCh) + s.mu.Unlock() + s.stdin.Close() + s.stdout.Close() + s.stderr.Close() + if s.cmd.Process != nil { + s.cmd.Process.Kill() + } + return nil +} + +func (s *pipeSession) Wait() error { + if s.cmd.Process == nil { + return nil + } + return s.cmd.Wait() +} + +func (s *pipeSession) Pid() int { + if s.cmd.Process == nil { + return 0 + } + return s.cmd.Process.Pid +} diff --git a/internal/version/version.go b/internal/version/version.go index f56c286..e79fe01 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -7,7 +7,7 @@ import ( const ( Name = "muyue" - Version = "0.7.0" + Version = "0.7.1" Author = "La Légion de Muyue" )