Cap concurrent ssh connections a batch opens at once

Deleting or importing several Claude Code sessions fired one ssh
process per item, all in the same tick. A large enough batch opened
more connections than the remote sshd's default MaxStartups tolerates
before it starts randomly refusing, so some rows failed with
"Connection closed by ... port 2222" -- not a real delete failure,
just too many handshakes landing at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet 5 committed 2026-09-01 00:23:23 -04:00
1 parent 7a8811aab3
commit 927b638feb
2 files changed
+26

No files matched your search

+19
View File
@@ -21,12 +21,31 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::sync::LazyLock;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::process::Child; use tokio::process::Child;
use tokio::sync::Semaphore;
use crate::config::SshConfig; use crate::config::SshConfig;
/// How many [`Transport::capture`] calls may have an ssh connection open at
/// once, across every setup.
///
/// `capture` is what a batch (deleting several imports, listing several
/// machines) fans out over -- one child `ssh` process per call, all started
/// within the same tick. Nothing here throttled that, so a batch large
/// enough to open more connections than the remote sshd's default
/// `MaxStartups` (10, before it starts randomly refusing) has some of
/// them come back as "Connection closed" -- not a real failure of the
/// operation, just too many handshakes landing on the listener at once. Four
/// keeps a batch comfortably under that ceiling while still overlapping the
/// network round trips. Long-lived processes (`Transport::spawn`, a
/// session's own child) don't take a permit: they hold it for the session's
/// lifetime rather than for one round trip, which would starve every other
/// probe behind it.
pub(crate) static CAPTURE_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(4));
/// What a driver needs run in order to exist as a process. /// What a driver needs run in order to exist as a process.
/// ///
/// Deliberately just the three things every transport can carry. Anything /// Deliberately just the three things every transport can carry. Anything
+7
View File
@@ -163,6 +163,13 @@ pub fn tidy(value: &str) -> Option<String> {
/// Runs a launch to completion and returns its stdout. /// Runs a launch to completion and returns its stdout.
impl Transport { impl Transport {
pub async fn capture(&self, launch: &Launch) -> Result<String> { pub async fn capture(&self, launch: &Launch) -> Result<String> {
// See `CAPTURE_PERMITS`: caps how many of these run their ssh
// connection at once, so a batch doesn't open more than the remote
// sshd tolerates before it starts dropping them.
let _permit = super::session::transport::CAPTURE_PERMITS
.acquire()
.await
.expect("capture semaphore is never closed");
let child = self.spawn(launch, super::session::transport::Streams::Piped)?; let child = self.spawn(launch, super::session::transport::Streams::Piped)?;
let output = child let output = child
.wait_with_output() .wait_with_output()