Fix sshExecScript hang: explicit exit instead of relying on PTY stdin EOF

Root cause of the "script finishes, process never returns" hangs (ci
runs #56 and #60, and a direct local reproduction): SSH channel EOF just
tells the server "not sending more data right now" -- for a plain exec
channel that maps onto closing the child's stdin pipe (clean, unconditional
EOF on read()), but for a PTY-backed session the server is managing an
interactive terminal, where a pause in input isn't supposed to mean "hang
up". It doesn't reliably translate into an EOF condition the remote
bash's read() ever sees, so bash just waits forever for a keystroke that
never comes.

This held true even after switching to Start()+StdinPipe()+explicit
Close() (the fix that did work for uploadFile, which uses a plain
non-PTY session) -- ruling out Run()'s implicit copying as the cause.

Confirmed independently outside this codebase: golang/go#6088 (the exact
same x/crypto/ssh symptom) and net-ssh/net-ssh#460 (different language,
same symptom, and confirms removing request_pty fixes it) -- this is a
general PTY/SSH-protocol interaction, not a library bug.

Fix: append `exit $?` to the script fed to bash -s, so it terminates the
instant the last command finishes, independent of whatever the PTY does
or doesn't do with EOF. Verified against a real box with a script that
prints its own completion timestamp then keeps running unless killed --
local process now returns within seconds of the remote script finishing
instead of hanging indefinitely.
This commit is contained in:
Philip Peterson 2026-08-05 02:25:01 -08:00
parent bf8e5f6f13
commit c3e94b7484

42
main.go
View file

@ -467,6 +467,23 @@ func uploadFile(st *state, localPath, remotePath string) error {
// `export` lines for env. Values are forwarded only over the SSH channel's
// stdin — never as a command-line argument on either end — so they don't
// show up in `ps` output on the box or in this process's argv.
// sshExecScript pipes script to `bash -s` over a PTY (needed so the remote
// side line-buffers instead of block-buffering -- see sshSession).
//
// The script ends with an explicit `exit $?` rather than relying on EOF to
// end bash's read loop. Confirmed in production (ci runs #56 and #60,
// plus a direct reproduction: a script that finishes and prints its own
// "end" timestamp, after which the local process sits for 10+ more
// minutes) that PTY stdin EOF is not a reliable "hang up" signal the way
// it is on a plain pipe -- a PTY is fundamentally for interactive
// sessions, where "stopped sending input" doesn't inherently mean
// "terminate". This held true even after switching to
// Start()+StdinPipe()+explicit Close() (the fix that *did* work for
// uploadFile, which uses a plain, non-PTY session) -- ruling out
// Run()'s implicit copying as the cause and pointing at the PTY itself.
// An explicit `exit` sidesteps the ambiguity entirely: bash terminates on
// its own the instant the script's last command finishes, independent of
// whatever the PTY does or doesn't do with EOF.
func sshExecScript(st *state, script string, env map[string]string) (int, error) {
session, client, err := sshSession(st, true)
if err != nil {
@ -480,11 +497,32 @@ func sshExecScript(st *state, script string, env map[string]string) (int, error)
fmt.Fprintf(&buf, "export %s=%s\n", k, shellQuote(v))
}
buf.WriteString(script)
buf.WriteString("\nexit $?\n")
session.Stdin = strings.NewReader(buf.String())
stdin, err := session.StdinPipe()
if err != nil {
return -1, err
}
session.Stdout = os.Stdout
session.Stderr = os.Stderr
return runSession(session, "bash -s")
if err := session.Start("bash -s"); err != nil {
return -1, err
}
if _, err := stdin.Write([]byte(buf.String())); err != nil {
return -1, err
}
if err := stdin.Close(); err != nil {
return -1, err
}
if err := session.Wait(); err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
return exitErr.ExitStatus(), nil
}
return -1, err
}
return 0, nil
}
func shellQuote(v string) string {