Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion cmd/machine/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,48 @@ func setupInteractivePTY(

t := resolvePTYTermWithFallback(ctx, sshClient, options.SessionOptions, options.Stderr)
width, height := getTerminalSize(fd)
if err = session.RequestPty(t, height, width, ssh.TerminalModes{}); err != nil {

// Configure terminal modes for PTY to address TUI rendering issues.
//
// Modes based on SSH implementations:
// - Tailscale: https://github.com/tailscale/tailscale/blob/main/ssh/tailssh/incubator.go
// - Coder: https://github.com/coder/coder/blob/main/pty/ssh_other.go (based on Tailscale)
// - NetBird: https://github.com/netbirdio/netbird/blob/main/client/ssh/client/terminal_unix.go
//
// Terminal modes consist of two types:
// 1. Control characters (CC values): VINTR, VQUIT, etc. - define which keys do what
// 2. Mode flags (boolean): ISIG, ICANON, OPOST, etc. - enable/disable terminal features
termModes := ssh.TerminalModes{
ssh.TTY_OP_ISPEED: 14400, // Input speed (baud rate)
ssh.TTY_OP_OSPEED: 14400, // Output speed (baud rate)
ssh.ICRNL: 1, // Map CR to NL on input (Enter key works correctly)
ssh.IXON: 1, // Enable XON/XOFF flow control on output
ssh.IXOFF: 1, // Enable XON/XOFF flow control on input
ssh.OPOST: 1, // Enable output processing (required for ONLCR)
ssh.ONLCR: 1, // Map NL to CR-NL on output (line breaks work correctly)
ssh.ISIG: 1, // Enable signals (Ctrl-C, Ctrl-Z work)
ssh.ICANON: 1, // Enable canonical mode (line editing works)
ssh.ECHO: 1, // Enable echoing of input characters
ssh.ECHOE: 1, // Erase character erases previous character
ssh.ECHOK: 1, // Kill character erases current line
ssh.IEXTEN: 1, // Enable extended input processing
ssh.VINTR: 3, // Ctrl-C (interrupt/SIGINT)
ssh.VQUIT: 28, // Ctrl-\ (quit/SIGQUIT with core dump)
ssh.VERASE: 127, // Backspace (erase previous character)
ssh.VKILL: 21, // Ctrl-U (erase entire line)
ssh.VEOF: 4, // Ctrl-D (end of file)
ssh.VEOL: 0, // End of line character (disabled)
ssh.VEOL2: 0, // Alternate end of line (disabled)
ssh.VSTART: 17, // Ctrl-Q (XON - resume output)
ssh.VSTOP: 19, // Ctrl-S (XOFF - stop output)
ssh.VSUSP: 26, // Ctrl-Z (suspend/SIGTSTP)
ssh.VREPRINT: 18, // Ctrl-R (reprint current line)
ssh.VWERASE: 23, // Ctrl-W (erase previous word)
ssh.VLNEXT: 22, // Ctrl-V (literal next - quote next character)
ssh.VDISCARD: 15, // Discard output (flush)
}

if err = session.RequestPty(t, height, width, termModes); err != nil {
restoreTerm()
return noopRestore, fmt.Errorf("request pty: %w", err)
}
Expand Down
27 changes: 21 additions & 6 deletions pkg/stdio/conn.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stdio

import (
"bufio"
"io"
"net"
"os"
Expand All @@ -9,8 +10,9 @@ import (

// StdioStream is the struct that implements the net.Conn interface.
type StdioStream struct {
in io.Reader
out io.WriteCloser
in *bufio.Reader
out *bufio.Writer
outRaw io.WriteCloser
local *StdinAddr
remote *StdinAddr

Expand All @@ -19,12 +21,14 @@ type StdioStream struct {
}

// NewStdioStream is used to implement the connection interface.
// Uses buffered I/O to prevent terminal escape sequence fragmentation.
func NewStdioStream(in io.Reader, out io.WriteCloser, exitOnClose bool, exitCode int) *StdioStream {
return &StdioStream{
local: NewStdinAddr("local"),
remote: NewStdinAddr("remote"),
in: in,
out: out,
in: bufio.NewReaderSize(in, 32*1024),
out: bufio.NewWriterSize(out, 32*1024),
outRaw: out,
exitOnClose: exitOnClose,
exitCode: exitCode,
}
Expand All @@ -46,18 +50,29 @@ func (s *StdioStream) Read(b []byte) (n int, err error) {
}

// Write implements interface.
// Flushes immediately to prevent escape sequence fragmentation.
func (s *StdioStream) Write(b []byte) (n int, err error) {
return s.out.Write(b)
n, err = s.out.Write(b)
if err != nil {
return n, err
}
return n, s.out.Flush()
}

// Close implements interface.
func (s *StdioStream) Close() error {
flushErr := s.out.Flush()
closeErr := s.outRaw.Close()

if s.exitOnClose {
// We kill ourself here because the streams are closed
os.Exit(s.exitCode)
}

return s.out.Close()
if flushErr != nil {
return flushErr
}
return closeErr
}

// SetDeadline implements interface.
Expand Down
77 changes: 77 additions & 0 deletions pkg/stdio/conn_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package stdio

import (
"bytes"
"io"
"strings"
"testing"

"github.com/stretchr/testify/suite"
)

type StdioStreamSuite struct {
suite.Suite
}

func TestStdioStreamSuite(t *testing.T) {
suite.Run(t, new(StdioStreamSuite))
}

func (s *StdioStreamSuite) TestStdioStreamBuffering() {
input := strings.NewReader("test input")
output := &bytes.Buffer{}
wc := &testWriteCloser{Buffer: output}

stream := NewStdioStream(input, wc, false, 0)

// Test write and flush
testData := []byte("test escape sequence \x1b[2J")
n, err := stream.Write(testData)
s.NoError(err, "Write should not fail")
s.Equal(len(testData), n, "Should write all bytes")

// Verify data was flushed immediately
s.Equal(testData, output.Bytes(), "Output should match input")
}

func (s *StdioStreamSuite) TestStdioStreamRead() {
testData := "test data with escape sequences \x1b[H\x1b[2J"
input := strings.NewReader(testData)
output := &bytes.Buffer{}
wc := &testWriteCloser{Buffer: output}

stream := NewStdioStream(input, wc, false, 0)

// Read data
buf := make([]byte, 1024)
n, err := stream.Read(buf)
s.True(err == nil || err == io.EOF, "Read should succeed or return EOF")
s.Equal(len(testData), n, "Should read all bytes")
s.Equal(testData, string(buf[:n]), "Read data should match")
}

func (s *StdioStreamSuite) TestStdioStreamClose() {
input := strings.NewReader("")
output := &bytes.Buffer{}
wc := &testWriteCloser{Buffer: output}

stream := NewStdioStream(input, wc, false, 0)

testData := []byte("buffered data")
_, _ = stream.out.Write(testData) // Write to buffer without flushing

err := stream.Close()
s.NoError(err, "Close should not fail")
s.True(wc.closed, "Underlying writer should be closed")
s.Equal(testData, output.Bytes(), "Buffered data should be flushed before close")
}

type testWriteCloser struct {
*bytes.Buffer
closed bool
}

func (w *testWriteCloser) Close() error {
w.closed = true
return nil
}
Loading