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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, | |||
| 344 | 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 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. | |
| 351 | 352 | func DialContext(ctx context.Context, target, session string, logger ...*slog.Logger) (ports.Transport, error) { | |
| 352 | 353 | var log *slog.Logger | |
| 353 | 354 | if len(logger) > 0 { | |
| @@ -361,7 +362,12 @@ func dialContext(ctx context.Context, target, session string, log *slog.Logger, | |||
| 361 | 362 | return nil, err | |
| 362 | 363 | } | |
| 363 | 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 | 371 | stdin, err := cmd.StdinPipe() | |
| 366 | 372 | if err != nil { | |
| 367 | 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 | 388 | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 383 | 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 | 394 | for _, opt := range opts { | |
| 385 | 395 | if opt != nil { | |
| 386 | 396 | 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 a | 347 | + | // DialContext is like Dial, but the context gates ssh startup. Once the |
| 348 | - | // canceled attach attempt interrupts the local ssh process. Callers may pass a | 348 | + | // transport is returned, its Close method owns the subprocess lifetime; the |
| 349 | - | // logger to record ssh start failures and non-clean exits without logging the | 349 | + | // 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.Logger | 353 | 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, err | 362 | 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) | ||