Forward the local registry port over the OS ssh client - #1887
Conversation
net-ssh's in-process reverse forwarding deadlocks when a large amount of data flows back through the tunnel: pulling an image whose changed layer exceeds ~27 MiB from the local registry hangs `kamal deploy` in `Build#pull`. The single `ssh.loop(0.1)` pump starves while the main thread drives the pull, the forwarded channel's window is exhausted, and the transfer wedges (stalls deterministically at the same byte offset, with TCP retransmits). A plain OS `ssh -R` carries the identical pull fine. Carry the reverse tunnel over the OS `ssh` client instead of net-ssh's in-process forwarding. The local-registry design is unchanged; only the transport differs. The existing ssh_options are mapped to the equivalent ssh flags. Fixes basecamp#1886. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses local-registry deploy hangs by switching reverse port forwarding from net-ssh in-process forwarding to an OS ssh -R tunnel, using a readiness handshake to preserve the “block until tunnel is up” behavior.
Changes:
- Replace net-ssh reverse forwarding with an OS
sshsubprocess and a READY token handshake. - Add lifecycle management for the spawned
sshprocesses (startup, readiness wait, teardown). - Add unit tests validating
sshcommand construction and option mapping.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| lib/kamal/cli/build/port_forwarding.rb | Implements OS-ssh reverse tunnel startup/readiness/teardown and maps Kamal SSH options to ssh flags. |
| test/cli/build/port_forwarding_test.rb | Adds unit coverage for the constructed ssh command and option mappings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def forward_ports | ||
| hosts.each do |host| | ||
| @children << start_forward(host) | ||
| wait_until_ready(@children.last) | ||
| end |
There was a problem hiding this comment.
Good catch — fixed in 9ae8f7a. All tunnels are now spawned before any readiness wait, so they establish in parallel (as the net-ssh version did) and READY_TIMEOUT is enforced as a single deadline shared across hosts, restoring the 30s-total contract.
| def config_option | ||
| ssh_options[:config] ? [ "-F", ssh_options[:config].to_s ] : [] | ||
| end |
There was a problem hiding this comment.
Fixed in 9ae8f7a. nil/true now emit no flag so the OS client reads its default configs, false maps to -F /dev/null, and a path or one-element array maps to -F. For multiple config files I went with a clear error rather than emitting multiple -F flags: OpenSSH honors only the last -F, so passing them all through would silently drop files. Faithful multi-file support would need a generated config using Include — happy to add that if it's wanted.
| def key_options | ||
| options = Array(ssh_options[:keys]).flat_map { |key| [ "-i", key.to_s ] } | ||
| options += [ "-o", "IdentitiesOnly=yes" ] if ssh_options[:keys_only] | ||
| options += [ "-o", "ForwardAgent=yes" ] if ssh_options[:forward_agent] | ||
| options | ||
| end |
There was a problem hiding this comment.
Agreed — fixed in 9ae8f7a: key_data now raises a clear error up front instead of being silently ignored. I went with fail-fast rather than a temporary identity file since that would write private key material to disk, and key_data is already deprecated for removal in Kamal 3. Can switch to the tempfile approach if you'd rather keep that path working.
| test "maps a proxy command to ProxyCommand" do | ||
| command = ssh_command(proxy: Net::SSH::Proxy::Command.new("connect -S relay %h %p")) | ||
|
|
||
| assert_includes_sequence command, [ "-o", "ProxyCommand=connect -S relay %h %p" ] | ||
| end | ||
|
|
||
| private |
There was a problem hiding this comment.
Added in 9ae8f7a: coverage for config as unset/true/false/path/one-element array/multiple files, the key_data error, and the shared readiness deadline.
- Map every documented ssh config type to the right flag: nil/true lets ssh read its default config files, false ignores them via -F /dev/null, and a single path (or one-element array) maps to -F. Multiple config files raise instead of silently dropping all but the last -F. - Fail fast with a clear error when key_data is configured, instead of silently ignoring the inline keys and failing auth later. - Spawn all tunnels before waiting so they establish in parallel and READY_TIMEOUT stays a single 30s deadline shared across hosts, as with the previous net-ssh implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| "-o", "ExitOnForwardFailure=yes", | ||
| "-o", "BatchMode=yes", | ||
| "-o", "ServerAliveInterval=#{ssh_options[:keepalive_interval] || 15}", | ||
| "-o", "ServerAliveCountMax=4", |
There was a problem hiding this comment.
Fair on the host key half — fe79052 adds -o StrictHostKeyChecking=accept-new, which restores net-ssh's default policy exactly (accept_new_or_local_tunnel: unknown keys are accepted silently, changed keys still fail), so a first-contact host works without prompting. I've kept BatchMode=yes though: a background tunnel subprocess can't reliably service password/passphrase prompts — in CI there's no TTY and the deploy would just hang until the readiness timeout, and in a terminal the prompt would race the 30s deadline. Failing fast with ssh's error message seems strictly better there.
| rescue Timeout::Error | ||
| raise "Timed out waiting for port forwarding to be established" |
There was a problem hiding this comment.
Fixed in fe79052 — the timeout error now names the first host that failed to become ready.
StrictHostKeyChecking=accept-new restores net-ssh's default host key policy (accept unknown keys silently, still reject changed ones), so a first-contact host doesn't fail under BatchMode. BatchMode stays: a background tunnel can't service password or passphrase prompts, so failing fast beats hanging until the readiness deadline. Also include the host in the readiness timeout error to make multi-host failures diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| "-o", "ServerAliveInterval=#{ssh_options[:keepalive_interval] || 15}", | ||
| "-o", "ServerAliveCountMax=4", |
| def terminate(wait_thread) | ||
| Process.kill "TERM", wait_thread.pid | ||
| return if wait_thread.join(TEARDOWN_GRACE) |
| assert_includes_sequence command, [ "-o", "ServerAliveInterval=45" ] | ||
| end | ||
|
|
Omit the ServerAlive options when ssh_options[:keepalive] is explicitly false, matching net-ssh, and skip TERM/KILL once the ssh process has been reaped so a recycled PID can't be signaled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| Timeout.timeout(remaining) do | ||
| while (line = child[:output].gets) | ||
| return if line.strip == READY_TOKEN | ||
| end | ||
|
|
||
| raise "Failed to establish port forward on #{child[:host]} (ssh exited #{child[:wait_thread].value.exitstatus})" | ||
| end |
Fixes #1886.
Problem
With the local registry (
registry: { server: localhost:5555 }),kamal deployhangs inBuild#pullonce an image layer exceeds ~27 MiB.Kamal::Cli::Build::PortForwardingcarries the reverse tunnel with net-ssh (ssh.forward.remote+ a singlessh.loop(0.1)in a background thread). Duringdocker pullthe bulk data flows host → deploy-machine; that forwarded channel's pump starves while the main thread drives the pull over its own net-ssh/SSHKit connection, the channel window is exhausted, and the transfer wedges. I could reproduce it deterministically — the tunnel froze at the same byte offset (~27.3 MiB) every time, with TCP retransmits. A plain OSssh -Rreverse tunnel carries the identical pull with no problem, which points at the net-ssh transport rather than the network. (Likely the same root cause as theErrno::ERANGE: send(2)reported in #1690.)Fix
Establish the reverse forward with the OS
sshclient instead of net-ssh's in-process forwarding. The local-registry design is unchanged — same-R 127.0.0.1:port:localhost:portreverse tunnel, registry stays onlocalhost— only the transport differs. Readiness is detected via a token printed by the remote command, which (withExitOnForwardFailure=yes) only runs once the forward is established, preserving the existing "block until the tunnel is up, 30s timeout" contract. The existingssh_options(user, port, keys, keys_only, config, forward_agent, keepalive, proxy) are mapped to the equivalentsshflags.Testing
test/cli/build/port_forwarding_test.rb), including a guard that-Nis never passed (it would suppress the readiness command and hang the deploy).build_test.rbport-forwarding / pull tests (which mockPortForwarding) still pass.sshtunnel.rubocopclean.Known limitation
ssh_options[:key_data](inline key material) can't be passed to the OSsshclient as a flag, so that path now relies on an agent / on-disk key.key_datais already deprecated for removal in Kamal 3. Happy to handle it (e.g. via a temporary identity file) — or to rework this toward a net-ssh-level fix instead, if you'd prefer to keep the transport in-process.