Skip to content

fix(sftp): send channel EOF+CLOSE when the SFTP session ends (fixes #215, sshj/Cyberduck hang)#216

Closed
Yaminyam wants to merge 2 commits into
lablup:mainfrom
Yaminyam:fix/sftp-send-channel-close-on-session-end
Closed

fix(sftp): send channel EOF+CLOSE when the SFTP session ends (fixes #215, sshj/Cyberduck hang)#216
Yaminyam wants to merge 2 commits into
lablup:mainfrom
Yaminyam:fix/sftp-send-channel-close-on-session-end

Conversation

@Yaminyam

@Yaminyam Yaminyam commented Jul 6, 2026

Copy link
Copy Markdown
Member

Problem

Fixes #215. sshj/JSch-based SFTP clients (Cyberduck, PyCharm/IntelliJ) hang ~30s then fail against bssh-server, while OpenSSH sftp/FileZilla work fine.

Root cause: the SFTP subsystem ran russh_sftp::server::run(), which spawned the request loop on a detached task and returned immediately — so when the session ended, the handler never sent the server-side channel EOF/CLOSE (unlike the shell/exec/SCP paths). OpenSSH/FileZilla tolerate the missing CLOSE; sshj/JSch strictly wait for the server's SSH_MSG_CHANNEL_CLOSE, so SFTPClient.close() blocks until its 30s timeout.

Fix

  • bssh-russh-sftp: run_with_config runs the loop inline instead of tokio::spawn-ing internally, so the caller can observe completion. (run() is only called from the server handler.)
  • handler: run the SFTP session on our own task and, on completion, send handle.eof() + handle.close() (mirroring shell/exec/SCP). Also close on the user-info error paths.

Verification

Minimal sshj 0.38 client (canonicalizestatlsclose): ~30s → ~1s. Regression: OpenSSH sftp (ls/get/put), paramiko, cargo test -p bssh-russh-sftp all fine.

Depends on / stacked on #212 (russh 0.62 migration) — the fix builds on the channel.into_stream() + session.handle() handling introduced there. Please merge after #212; the diff will collapse to just the two changed files once #212 lands.

The upstream russh-sftp counterpart (making run awaitable) is proposed at AspectUnk/russh-sftp#93; once released we can drop the bssh-russh-sftp vendored fork.

Both reasons the fork existed are now upstreamed:
- high-frequency PTY output fix (Handle::data from spawned tasks):
  upstream #731, released in russh 0.62.0
- SHA-1 MAC exclusion from Preferred::DEFAULT:
  upstream #690, released in russh 0.60.2

So crates/bssh-russh is no longer needed. Switch the dependency to the
crates.io russh 0.62.1 and remove the vendored fork (66 files).

- Cargo.toml: russh = "0.62.1" (was package = bssh-russh, path fork)
- ssh-key pin bumped =0.7.0-rc.10 -> =0.7.0-rc.11 to match russh 0.62.x
- handler: adapt channel_open_session to the new russh 0.62 signature
  (ChannelOpenHandle reply + Result<()> instead of Result<bool>);
  accept the channel via reply.accept()
- audit.toml: reword the RUSTSEC-2023-0071 note (no more vendored fork)

Verified: cargo build, clippy, fmt clean, and full test suite green
(1233 lib tests, PTY stress 10/10, streaming high-throughput 28/28).
@Yaminyam Yaminyam force-pushed the fix/sftp-send-channel-close-on-session-end branch from 7a4dc44 to 2ddd396 Compare July 6, 2026 10:18
The SFTP subsystem ran `russh_sftp::server::run()`, which spawned the SFTP
request loop on a detached task and returned nothing, so the handler had
no completion hook and never sent the server-side channel EOF/CLOSE after
the session finished (unlike the shell/exec/SCP paths, which send
exit_status + eof + close). Clients that block on the SSH channel-close
handshake — notably sshj/JSch (Cyberduck, PyCharm, IntelliJ) — then wait
for a CLOSE that never arrives and hang ~30s before timing out. OpenSSH
`sftp`/FileZilla tolerate the missing CLOSE, so it only showed up with
sshj-based clients.

- bssh-russh-sftp: `run`/`run_with_config` now return the spawned task's
  `JoinHandle<()>` (resolves on client EOF) so callers can observe when the
  session ends. Backward compatible — dropping the handle keeps the old
  fire-and-forget behaviour. Mirrors the upstream proposal
  AspectUnk/russh-sftp#93.
- handler: await that handle on a detached task and then send
  `handle.eof()` + `handle.close()` (mirroring the shell/exec/SCP paths).
  Also close the channel on the user-info error paths.

Reproduced with an sshj 0.38 client (`canonicalize` + `stat` + `ls` +
close): before, `SFTPClient.close()` timed out after 30s; after, the
session completes and closes in ~1s. OpenSSH `sftp` and paramiko are
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Yaminyam Yaminyam force-pushed the fix/sftp-send-channel-close-on-session-end branch from 2ddd396 to dbc7ec7 Compare July 6, 2026 10:26
@Yaminyam

Yaminyam commented Jul 6, 2026

Copy link
Copy Markdown
Member Author

Redundant: #215 is fixed by the russh 0.62.1 upgrade in #212 (russh #675 replies with CHANNEL_CLOSE per RFC 4254). Verified that clean #212 with no SFTP-layer change resolves the sshj/Cyberduck hang, so this explicit handle.close() isn't needed. Closing.

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.

SFTP: Cyberduck (sshj) connection dropped on initial REALPATH/STAT — server errors SshEncoding: length invalid

1 participant