detached 3fedffb

fix(sshstdio): keep carriage alive after handshake

commit
3fedffb524e36334b591afcbd7cbd1256edd92f3
author
brice · · 2026-08-03 14:57 UTC
committer
brice · · 2026-08-04 13:34 UTC
parent
6e29c9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +15 -5
M internal/adapters/sshstdio/transport.go +15 -5
@@ -344,10 +344,11 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
344344 return dialContext(ctx, target, session, logger, opts...)
345345 }
346346
347-// DialContext is like Dial, but the context is propagated to ssh startup so a
348-// canceled attach attempt interrupts the local ssh process. Callers may pass a
349-// logger to record ssh start failures and non-clean exits without logging the
350-// generated command line.
347+// DialContext is like Dial, but the context gates ssh startup. Once the
348+// transport is returned, its Close method owns the subprocess lifetime; the
349+// handshake context must not kill an already-established carriage. Callers may
350+// pass a logger to record ssh start failures and non-clean exits without logging
351+// the generated command line.
351352 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
352353 var log *slog.Logger
353354 if len(logger) > 0 {
@@ -361,7 +362,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
361362 return nil, err
362363 }
363364 spec := BuildCommand(target, session)
364- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
365+ // The caller's context is also the bounded protocol-handshake context and is
366+ // canceled after the first committed publication. Binding it to the command
367+ // would kill a healthy long-lived carriage at that boundary. Transport.Close
368+ // owns cancellation after Start; the preflight check above keeps canceled
369+ // attempts from starting a process.
370+ cmd := exec.Command(spec.Path, spec.Args...)
365371 stdin, err := cmd.StdinPipe()
366372 if err != nil {
367373 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
@@ -381,6 +387,10 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
381387
382388 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
383389 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
390+ if err := ctx.Err(); err != nil {
391+ _ = transport.Close()
392+ return nil, err
393+ }
384394 for _, opt := range opts {
385395 if opt != nil {
386396 opt(transport)
@@ -344,10 +344,11 @@ func DialContextWithRuntimeObserver(ctx context.Context, target, session string,
344 return dialContext(ctx, target, session, logger, opts...)344 return dialContext(ctx, target, session, logger, opts...)
345 }345 }
346 346
347-// DialContext is like Dial, but the context is propagated to ssh startup so a347+// DialContext is like Dial, but the context gates ssh startup. Once the
348-// canceled attach attempt interrupts the local ssh process. Callers may pass a348+// transport is returned, its Close method owns the subprocess lifetime; the
349-// logger to record ssh start failures and non-clean exits without logging the349+// handshake context must not kill an already-established carriage. Callers may
350-// generated command line.350+// pass a logger to record ssh start failures and non-clean exits without logging
351+// the generated command line.
351 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {352 func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) {
352 var log *slog.Logger353 var log *slog.Logger
353 if len(logger) > 0 {354 if len(logger) > 0 {
@@ -361,7 +362,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
361 return nil, err362 return nil, err
362 }363 }
363 spec := BuildCommand(target, session)364 spec := BuildCommand(target, session)
364- cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)365+ // The caller's context is also the bounded protocol-handshake context and is
366+ // canceled after the first committed publication. Binding it to the command
367+ // would kill a healthy long-lived carriage at that boundary. Transport.Close
368+ // owns cancellation after Start; the preflight check above keeps canceled
369+ // attempts from starting a process.
370+ cmd := exec.Command(spec.Path, spec.Args...)
365 stdin, err := cmd.StdinPipe()371 stdin, err := cmd.StdinPipe()
366 if err != nil {372 if err != nil {
367 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)373 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
@@ -381,6 +387,10 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger,
381 387
382 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)388 waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
383 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)389 transport := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
390+ if err := ctx.Err(); err != nil {
391+ _ = transport.Close()
392+ return nil, err
393+ }
384 for _, opt := range opts {394 for _, opt := range opts {
385 if opt != nil {395 if opt != nil {
386 opt(transport)396 opt(transport)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close