Skip to content

Conversation

@skevetter
Copy link
Owner

@skevetter skevetter commented Feb 5, 2026

  • refactor(ssh): options to struct and handle ssh exit codes
  • fix: apply comment feedback

Summary by CodeRabbit

  • Refactor
    • Improved SSH command execution handling across tunneling and SSH operations, including enhanced error handling and context management for better reliability.

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

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

This PR refactors the SSH command execution API by replacing positional parameters with a structured RunOptions approach. The main implementation in pkg/ssh/helper.go introduces the RunOptions struct, a new ExitError type for wrapping exit codes, and improved error handling. Call sites across multiple packages are updated to use the new struct-based API.

Changes

Cohort / File(s) Summary
Core SSH API
pkg/ssh/helper.go
Introduces RunOptions struct encapsulating context, client, command, and I/O streams; adds ExitError type; implements context cancellation setup; adds input validation; improves error handling with nuanced treatment of specific exit codes (130, 129, 143).
SSH Command Invocations
cmd/ssh.go, pkg/tunnel/container.go, pkg/tunnel/services.go
Replace positional devssh.Run() calls with RunOptions-based invocations; maintain existing control flow and error handling.
SSH Integration Points
pkg/devcontainer/sshtunnel/sshtunnel.go, pkg/gpg/gpg_forwarding.go
Adapt SSH tunnel and GPG forwarding logic to use RunOptions-based API; preserve stream capture and error propagation semantics.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% 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 accurately summarizes the main changes: refactoring SSH Run calls to use a RunOptions struct and implementing SSH exit code handling across multiple files.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ssh-helper-run

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.

Signed-off-by: Samuel K <skevetter@pm.me>
@skevetter skevetter marked this pull request as ready for review February 5, 2026 23:18
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

🤖 Fix all issues with AI agents
In `@pkg/ssh/helper.go`:
- Around line 165-179: setupContextCancellation currently spawns a goroutine
that blocks on ctx.Done() and can leak if the SSH command finishes while ctx
remains alive; change setupContextCancellation to return a cleanup function
along with error (e.g., func setupContextCancellation(ctx context.Context, sess
*ssh.Session) (func(), error)), create a done channel (or rename exit) that the
goroutine selects on alongside ctx.Done(), and have the returned cleanup close
that channel to ensure the goroutine exits; keep the behavior of calling
sess.Signal(ssh.SIGINT) when ctx.Done() fires, and update callers to defer the
returned cleanup after Run/command completion.
- Around line 181-209: handleRunError currently suppresses signal-like exit
codes (129/130/143) unconditionally; change it to only treat those as non-errors
when the operation was actually canceled by context. Modify handleRunError to
accept a context.Context (e.g., handleRunError(ctx context.Context, err error,
command string)), and when errors.As finds an *ssh.ExitError and the exitCode is
129/130/143, check ctx.Err() (or ctx.Err() == context.Canceled) before returning
nil; otherwise return the existing &ExitError{ExitCode: exitCode, Err: exitErr}.
Keep all other error-wrapping behavior intact.

Comment on lines +165 to +179
func setupContextCancellation(ctx context.Context, sess *ssh.Session) error {
if err := ctx.Err(); err != nil {
return fmt.Errorf("context already cancelled: %w", err)
}
exit := make(chan struct{})
defer close(exit)
go func() {
defer close(exit)
select {
case <-ctx.Done():
_ = sess.Signal(ssh.SIGINT)
_ = sess.Close()
_ = sess.Signal(ssh.SIGINT) // Send interrupt, let defer handle close
case <-exit:
}
}()
return nil
}
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

Avoid goroutine leak when context isn’t canceled.

The goroutine waits on ctx.Done() forever if the command completes while the context stays alive, which can leak goroutines on long‑lived contexts. Consider returning a cleanup function and closing it after Run completes.

🛠️ Proposed fix
- if err := setupContextCancellation(opts.Context, sess); err != nil {
- 	return err
- }
+ stopCancel, err := setupContextCancellation(opts.Context, sess)
+ if err != nil {
+ 	return err
+ }
+ defer stopCancel()
...
-func setupContextCancellation(ctx context.Context, sess *ssh.Session) error {
+func setupContextCancellation(ctx context.Context, sess *ssh.Session) (func(), error) {
 	if err := ctx.Err(); err != nil {
-		return fmt.Errorf("context already cancelled: %w", err)
+		return func() {}, fmt.Errorf("context already cancelled: %w", err)
 	}
 	exit := make(chan struct{})
 	go func() {
 		defer close(exit)
 		select {
 		case <-ctx.Done():
 			_ = sess.Signal(ssh.SIGINT) // Send interrupt, let defer handle close
 		case <-exit:
 		}
 	}()
-	return nil
+	return func() { close(exit) }, nil
 }
🤖 Prompt for AI Agents
In `@pkg/ssh/helper.go` around lines 165 - 179, setupContextCancellation currently
spawns a goroutine that blocks on ctx.Done() and can leak if the SSH command
finishes while ctx remains alive; change setupContextCancellation to return a
cleanup function along with error (e.g., func setupContextCancellation(ctx
context.Context, sess *ssh.Session) (func(), error)), create a done channel (or
rename exit) that the goroutine selects on alongside ctx.Done(), and have the
returned cleanup close that channel to ensure the goroutine exits; keep the
behavior of calling sess.Signal(ssh.SIGINT) when ctx.Done() fires, and update
callers to defer the returned cleanup after Run/command completion.

Comment on lines +181 to 209
func handleRunError(err error, command string) error {
// Check for exit errors with exit codes
var exitErr *ssh.ExitError
if errors.As(err, &exitErr) {
exitCode := exitErr.ExitStatus()

// Exit codes 128+N indicate death by signal N
// 130 = 128 + 2 (SIGINT) - Ctrl+C (user interrupted)
// 129 = 128 + 1 (SIGHUP) - hangup (terminal closed)
// 143 = 128 + 15 (SIGTERM) - graceful termination
// These are "normal" ways to exit an interactive session
if exitCode == 130 || exitCode == 129 || exitCode == 143 {
return nil // Don't treat user interrupts as errors
}

// Return exit code for all other cases
return &ExitError{
ExitCode: exitCode,
Err: exitErr,
}
}

return nil
// Provide context for common errors
if errors.Is(err, io.EOF) {
return fmt.Errorf("SSH session closed unexpectedly (EOF) while running: %s", command)
}

return fmt.Errorf("SSH command failed: %w (command: %s)", err, command)
}
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

Don’t swallow signal exit codes unless cancellation was requested.

Returning nil for 129/130/143 unconditionally can mask genuine failures (e.g., remote SIGTERM). Consider only suppressing these when the context is actually canceled, otherwise propagate ExitError.

🛠️ Proposed fix
- return handleRunError(err, opts.Command)
+ return handleRunError(opts.Context, err, opts.Command)
...
-func handleRunError(err error, command string) error {
+func handleRunError(ctx context.Context, err error, command string) error {
 	// Check for exit errors with exit codes
 	var exitErr *ssh.ExitError
 	if errors.As(err, &exitErr) {
 		exitCode := exitErr.ExitStatus()
@@
-		if exitCode == 130 || exitCode == 129 || exitCode == 143 {
-			return nil // Don't treat user interrupts as errors
-		}
+		if exitCode == 130 || exitCode == 129 || exitCode == 143 {
+			if ctx.Err() != nil {
+				return nil // Don't treat user interrupts as errors
+			}
+			return &ExitError{ExitCode: exitCode, Err: exitErr}
+		}
🤖 Prompt for AI Agents
In `@pkg/ssh/helper.go` around lines 181 - 209, handleRunError currently
suppresses signal-like exit codes (129/130/143) unconditionally; change it to
only treat those as non-errors when the operation was actually canceled by
context. Modify handleRunError to accept a context.Context (e.g.,
handleRunError(ctx context.Context, err error, command string)), and when
errors.As finds an *ssh.ExitError and the exitCode is 129/130/143, check
ctx.Err() (or ctx.Err() == context.Canceled) before returning nil; otherwise
return the existing &ExitError{ExitCode: exitCode, Err: exitErr}. Keep all other
error-wrapping behavior intact.

@skevetter skevetter merged commit e172040 into main Feb 5, 2026
39 checks passed
@skevetter skevetter deleted the ssh-helper-run branch February 5, 2026 23:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant