All checks were successful
PR Check / check (pull_request) Successful in 57s
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.
199 lines
4.2 KiB
Go
199 lines
4.2 KiB
Go
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
|
|
}
|