Skip to content

fix(stdio): buffered stream and terminal modes for PTY for broken TUI#522

Open
skevetter wants to merge 3 commits intomainfrom
tmux-ssh-scrolling
Open

fix(stdio): buffered stream and terminal modes for PTY for broken TUI#522
skevetter wants to merge 3 commits intomainfrom
tmux-ssh-scrolling

Conversation

@skevetter
Copy link
Owner

@skevetter skevetter commented Feb 25, 2026

Signed-off-by: Samuel K skevetter@pm.me

Summary by CodeRabbit

  • Bug Fixes

    • Improved SSH terminal configuration for more consistent and compatible remote terminal behavior.
  • Performance

    • Added buffered input/output handling to reduce escape-sequence fragmentation and improve interactive stream responsiveness.
  • Tests

    • Added tests covering buffered I/O, read/write/close behavior, and terminal interaction to prevent regressions.

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Adds comprehensive PTY TerminalModes when requesting an SSH PTY and converts the stdio connection to buffered I/O (32 KiB reader/writer) with immediate flush behavior; also introduces tests for the buffered stdio behavior.

Changes

Cohort / File(s) Summary
PTY Terminal Configuration
cmd/machine/ssh.go
Populate and pass a comprehensive TerminalModes map to session.RequestPty (ISPEED/OSPEED, input/output flags, canonical/echo/flow control, and V* control characters) instead of an empty map.
Buffered I/O Implementation
pkg/stdio/conn.go
Change StdioStream.in*bufio.Reader, StdioStream.out*bufio.Writer, add outRaw (io.WriteCloser), instantiate 32 KiB buffers, flush on Write and Close, and adjust Close to close outRaw.
Buffered I/O Tests
pkg/stdio/conn_test.go
Add tests for immediate-flush write behavior, read/EOF semantics, and close semantics using a testWriteCloser helper and testify/suite.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Local as Local Process
participant Stdio as StdioStream
participant OutRaw as outRaw (writer)
Local->>Stdio: Write(data)
Stdio->>Stdio: bufio.Writer.Write (buffer)
Stdio->>OutRaw: Flush (on Write / Close)
OutRaw-->>Stdio: bytes written

mermaid
sequenceDiagram
participant CLI as CLI (cmd/machine)
participant SSH as SSH Client
participant Remote as Remote Server
CLI->>SSH: session.RequestPty(term, cols, rows, TerminalModes{ISPEED,OSPEED,ICANON,ECHO,...})
SSH->>Remote: PTY negotiation with modes
Remote-->>SSH: PTY allocated
SSH-->>CLI: session established

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

size/xl

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main changes: adding buffered streams and terminal modes configuration for PTY to fix TUI issues, which aligns with the actual modifications across cmd/machine/ssh.go and pkg/stdio/conn.go.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tmux-ssh-scrolling

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@skevetter skevetter changed the title fix(sdio): buffered stream and terminal modes for PTY for broken TUI fix(stdio): buffered stream and terminal modes for PTY for broken TUI Feb 25, 2026
@skevetter skevetter marked this pull request as ready for review February 25, 2026 07:11
@skevetter
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/stdio/conn_test.go`:
- Around line 60-66: The test currently only asserts wc.closed after
stream.Close(); also assert that buffered data was flushed by checking the
underlying buffer contents: after writing testData to stream.out and calling
stream.Close(), verify output.Bytes() (or output.String()) contains the original
testData (e.g., equals testData) to ensure Close flushed the buffer; update the
test that references stream, stream.out, wc.closed and output to include this
assertion.

In `@pkg/stdio/conn.go`:
- Around line 65-68: The current code returns immediately on s.out.Flush() error
which prevents closing the underlying writer (outRaw) and can leak descriptors;
change the logic in conn.go so you call s.out.Flush() and capture its error,
then always call outRaw.Close() (or s.outRaw.Close()) regardless of the flush
result, and finally return the appropriate error: if only flush failed return
that error, if only close failed return the close error, and if both failed
return a combined/wrapped error that preserves both causes. Ensure you reference
s.out.Flush() and outRaw.Close()/s.outRaw.Close() when making the changes.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d60c37b and 6b970eb.

📒 Files selected for processing (3)
  • cmd/machine/ssh.go
  • pkg/stdio/conn.go
  • pkg/stdio/conn_test.go

Comment on lines 65 to 68
// Flush any remaining buffered data
if err := s.out.Flush(); err != nil {
return err
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Always close the underlying writer, even if flush fails.

At Line 66, returning on Flush() error prevents Line 75 from executing, so outRaw may never close. That can leak descriptors and miss EOF signaling.

Proposed fix
 func (s *StdioStream) Close() error {
-	// Flush any remaining buffered data
-	if err := s.out.Flush(); err != nil {
-		return err
-	}
+	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.outRaw.Close()
+	if flushErr != nil {
+		return flushErr
+	}
+	return closeErr
 }

Also applies to: 75-75

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn.go` around lines 65 - 68, The current code returns immediately
on s.out.Flush() error which prevents closing the underlying writer (outRaw) and
can leak descriptors; change the logic in conn.go so you call s.out.Flush() and
capture its error, then always call outRaw.Close() (or s.outRaw.Close())
regardless of the flush result, and finally return the appropriate error: if
only flush failed return that error, if only close failed return the close
error, and if both failed return a combined/wrapped error that preserves both
causes. Ensure you reference s.out.Flush() and outRaw.Close()/s.outRaw.Close()
when making the changes.

Signed-off-by: Samuel K <skevetter@pm.me>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
pkg/stdio/conn.go (1)

63-73: ⚠️ Potential issue | 🟠 Major

Flush() failure still prevents outRaw.Close() — resource leak risk.

If s.out.Flush() returns an error at Line 64, the method returns early and outRaw.Close() on Line 73 is never reached, potentially leaking the file descriptor and preventing EOF signaling to the peer.

Proposed fix — always close the underlying writer
 func (s *StdioStream) Close() error {
-	if err := s.out.Flush(); err != nil {
-		return err
-	}
+	flushErr := s.out.Flush()
 
 	if s.exitOnClose {
 		os.Exit(s.exitCode)
 	}
 
-	return s.outRaw.Close()
+	closeErr := s.outRaw.Close()
+	if flushErr != nil {
+		return flushErr
+	}
+	return closeErr
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn.go` around lines 63 - 73, In StdioStream.Close, ensure the
underlying writer is always closed even if s.out.Flush() fails: call
s.outRaw.Close() regardless of Flush error (use a defer or run Close in a
finally-style block) and then handle exit logic (s.exitOnClose /
os.Exit(s.exitCode)) after attempting both operations; return the most relevant
error (prefer returning the flush error if present, or the close error if flush
succeeded) so callers still see failures but we avoid leaking the underlying
resource (refer to StdioStream.Close, s.out.Flush, s.outRaw.Close, s.exitOnClose
and s.exitCode).
🧹 Nitpick comments (2)
pkg/stdio/conn.go (1)

54-59: Write-side buffering is a no-op when every Write flushes immediately.

Since Flush() is called after every Write(), no data ever accumulates in the buffer across calls — each write goes straight to the underlying writer in one (or two, for writes > 32 KiB) syscalls, same as unbuffered I/O. The 32 KiB buffer allocation per stream adds overhead without providing the escape-sequence-coalescing benefit described in the comment.

If the goal is to prevent a single large write from being fragmented by the kernel, bufio.Writer doesn't help: it will itself split data at the buffer boundary. If the real benefit is on the read side only (reducing syscall overhead), consider dropping the write-side buffer and writing directly to outRaw:

Suggested simplification
 func (s *StdioStream) Write(b []byte) (n int, err error) {
-	n, err = s.out.Write(b)
-	if err != nil {
-		return n, err
-	}
-	return n, s.out.Flush()
+	return s.outRaw.Write(b)
 }

If there is a caller pattern where multiple small writes should be coalesced before flushing (e.g., batch rendering), the flush should be moved out of Write and into an explicit Flush() method or deferred to Close, not called on every write.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn.go` around lines 54 - 59, The Write method on StdioStream
currently calls s.out.Flush() on every Write, which negates the bufio.Writer
buffering; remove the per-Write flush and either write directly to the raw
writer (outRaw) or keep the bufio.Writer (s.out) but expose an explicit Flush
method and call it only when callers need coalescing (e.g., on Close or explicit
Flush), updating StdioStream.Write to just write to s.out (or outRaw) without
flushing and adding/using a StdioStream.Flush() that calls s.out.Flush() when
buffering is desired.
cmd/machine/ssh.go (1)

278-317: Baud rate values are lower than typical system defaults.

The terminal modes configuration is well-documented and correct overall. However, TTY_OP_ISPEED / TTY_OP_OSPEED at 14400 baud is notably lower than the standard OS PTY default of 38400 baud (common on Linux systems). While baud rates on pseudo-terminals are advisory and don't affect actual throughput, using 14400 is unusual. Consider aligning with the typical OS default of 38400 or higher (e.g., 115200):

-	ssh.TTY_OP_ISPEED: 14400, // Input speed (baud rate)
-	ssh.TTY_OP_OSPEED: 14400, // Output speed (baud rate)
+	ssh.TTY_OP_ISPEED: 38400, // Input speed (baud rate)
+	ssh.TTY_OP_OSPEED: 38400, // Output speed (baud rate)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/machine/ssh.go` around lines 278 - 317, The termModes map sets
ssh.TTY_OP_ISPEED and ssh.TTY_OP_OSPEED to 14400 which is below common PTY
defaults; update these entries in the termModes initialization (refer to
termModes, ssh.TTY_OP_ISPEED and ssh.TTY_OP_OSPEED) to a more typical value such
as 38400 (or 115200 if you prefer) so the baud rate aligns with standard OS PTY
defaults.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/stdio/conn.go`:
- Around line 63-73: In StdioStream.Close, ensure the underlying writer is
always closed even if s.out.Flush() fails: call s.outRaw.Close() regardless of
Flush error (use a defer or run Close in a finally-style block) and then handle
exit logic (s.exitOnClose / os.Exit(s.exitCode)) after attempting both
operations; return the most relevant error (prefer returning the flush error if
present, or the close error if flush succeeded) so callers still see failures
but we avoid leaking the underlying resource (refer to StdioStream.Close,
s.out.Flush, s.outRaw.Close, s.exitOnClose and s.exitCode).

---

Nitpick comments:
In `@cmd/machine/ssh.go`:
- Around line 278-317: The termModes map sets ssh.TTY_OP_ISPEED and
ssh.TTY_OP_OSPEED to 14400 which is below common PTY defaults; update these
entries in the termModes initialization (refer to termModes, ssh.TTY_OP_ISPEED
and ssh.TTY_OP_OSPEED) to a more typical value such as 38400 (or 115200 if you
prefer) so the baud rate aligns with standard OS PTY defaults.

In `@pkg/stdio/conn.go`:
- Around line 54-59: The Write method on StdioStream currently calls
s.out.Flush() on every Write, which negates the bufio.Writer buffering; remove
the per-Write flush and either write directly to the raw writer (outRaw) or keep
the bufio.Writer (s.out) but expose an explicit Flush method and call it only
when callers need coalescing (e.g., on Close or explicit Flush), updating
StdioStream.Write to just write to s.out (or outRaw) without flushing and
adding/using a StdioStream.Flush() that calls s.out.Flush() when buffering is
desired.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6b970eb and 0dba625.

📒 Files selected for processing (2)
  • cmd/machine/ssh.go
  • pkg/stdio/conn.go

@skevetter
Copy link
Owner Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai bot added the size/xl label Feb 25, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
pkg/stdio/conn.go (1)

62-76: Previous feedback addressed: outRaw.Close() is now always called regardless of flush outcome.

One remaining nit: if both Flush() and Close() fail, closeErr is silently discarded. Consider errors.Join(flushErr, closeErr) so callers see both failures.

Optional: preserve both errors
+	"errors"
 	"io"
-	if flushErr != nil {
-		return flushErr
-	}
-	return closeErr
+	return errors.Join(flushErr, closeErr)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn.go` around lines 62 - 76, In StdioStream.Close, if both
s.out.Flush() and s.outRaw.Close() return errors the current code returns only
flushErr and discards closeErr; change the return logic to preserve both errors
by using errors.Join(flushErr, closeErr) (falling back to the single non-nil
error when one is nil) while preserving the existing exit-on-close behavior that
uses s.exitOnClose and s.exitCode; update imports to include "errors" if not
already present.
pkg/stdio/conn_test.go (1)

53-67: Previous flush-assertion feedback has been addressed.

Line 66 now verifies buffered data is flushed before close — matches the earlier review request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn_test.go` around lines 53 - 67, TestStdioStreamClose correctly
verifies buffered data is flushed on Close: ensure the test continues to use
NewStdioStream to create stream, write unflushed bytes to stream.out, call
stream.Close(), assert no error, assert testWriteCloser.closed is true and
assert output.Bytes() equals testData; no code changes required since the
previous flush-assertion feedback has been addressed.
🧹 Nitpick comments (2)
pkg/stdio/conn_test.go (2)

37-51: Consider testing Read with a buffer smaller than the input to exercise buffered-reader behavior.

The current test uses a 1024-byte buffer for ~42 bytes of data, so the bufio.Reader wrapper is never really exercised. A complementary case with a small buffer (e.g., 8 bytes) and a loop would validate multi-read and buffering semantics, which is the whole point of the bufio.NewReaderSize change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn_test.go` around lines 37 - 51, Add a complementary test to
TestStdioStreamRead that exercises NewStdioStream's bufio.NewReaderSize behavior
by reading from the stream using a small buffer (e.g., 8 bytes) in a loop until
io.EOF, aggregating the bytes and asserting the total length and content equal
the original testData; locate this around the existing TestStdioStreamRead and
reuse the same input/testData and stream := NewStdioStream(...) setup so the
buffered-reader logic is exercised across multiple Read calls.

69-77: Consider adding an error-path helper to cover flush/close failure scenarios.

Currently all tests use a testWriteCloser that never fails. A variant that returns an error on Write or Close would let you verify that Write propagates flush errors and that Close returns the right error when flush or close fails (especially given the dual-error logic in conn.go).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/stdio/conn_test.go` around lines 69 - 77, Add a failing test helper type
to exercise error paths: implement a variant of testWriteCloser (e.g.,
failingWriteCloser) that embeds bytes.Buffer but whose Write and/or Close
methods return a supplied error; update tests that exercise conn.Write and
conn.Close to use this helper to assert that Write propagates flush errors and
that Close returns the correct error when flush fails or when the underlying
Close fails (covering the dual-error logic in conn.go and the methods named
testWriteCloser, Write, Close, and the conn Close/flush behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/stdio/conn_test.go`:
- Around line 53-67: TestStdioStreamClose correctly verifies buffered data is
flushed on Close: ensure the test continues to use NewStdioStream to create
stream, write unflushed bytes to stream.out, call stream.Close(), assert no
error, assert testWriteCloser.closed is true and assert output.Bytes() equals
testData; no code changes required since the previous flush-assertion feedback
has been addressed.

In `@pkg/stdio/conn.go`:
- Around line 62-76: In StdioStream.Close, if both s.out.Flush() and
s.outRaw.Close() return errors the current code returns only flushErr and
discards closeErr; change the return logic to preserve both errors by using
errors.Join(flushErr, closeErr) (falling back to the single non-nil error when
one is nil) while preserving the existing exit-on-close behavior that uses
s.exitOnClose and s.exitCode; update imports to include "errors" if not already
present.

---

Nitpick comments:
In `@pkg/stdio/conn_test.go`:
- Around line 37-51: Add a complementary test to TestStdioStreamRead that
exercises NewStdioStream's bufio.NewReaderSize behavior by reading from the
stream using a small buffer (e.g., 8 bytes) in a loop until io.EOF, aggregating
the bytes and asserting the total length and content equal the original
testData; locate this around the existing TestStdioStreamRead and reuse the same
input/testData and stream := NewStdioStream(...) setup so the buffered-reader
logic is exercised across multiple Read calls.
- Around line 69-77: Add a failing test helper type to exercise error paths:
implement a variant of testWriteCloser (e.g., failingWriteCloser) that embeds
bytes.Buffer but whose Write and/or Close methods return a supplied error;
update tests that exercise conn.Write and conn.Close to use this helper to
assert that Write propagates flush errors and that Close returns the correct
error when flush fails or when the underlying Close fails (covering the
dual-error logic in conn.go and the methods named testWriteCloser, Write, Close,
and the conn Close/flush behavior).

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0dba625 and 4608d34.

📒 Files selected for processing (2)
  • pkg/stdio/conn.go
  • pkg/stdio/conn_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant