a73x

c1123a5e

Run a sync's fetch and push over one SSH connection

a73x   2026-09-05 17:09

Commit message
Run a sync's fetch and push over one SSH connection

A sync is a `git fetch` and then a `git push`, two processes that each open
their own SSH connection: two handshakes, two authentications, two of
whatever the network charges. Nothing between them needs the remote, so the
push now rides the connection the fetch opened, through OpenSSH's
ControlMaster: the fetch's ssh becomes a persistent master, the push
multiplexes over it, and the master is closed when the sync ends.

The options are appended to whatever ssh command git would have run —
`GIT_SSH_COMMAND`, `core.sshCommand` or plain `ssh` — so a user's own key
and host-key policy still apply. Sharing is skipped, and each command opens
its own connection as before, when git would not be running OpenSSH at all,
or when the socket path would exceed the Unix socket limit (a long
`TMPDIR` must not turn into a sync that cannot fetch).

Against the production remote a sync goes from 4.9 s to 2.7 s; the test
counts logins in the server log and sees one per sync where it saw two.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

src/lib.rs
Old New
@@ -18,6 +18,7 @@ pub mod patch;
18 pub mod refs; 18 pub mod refs;
19 pub mod release; 19 pub mod release;
20 pub mod signing; 20 pub mod signing;
21 pub mod ssh_share;
21 pub mod state; 22 pub mod state;
22 pub mod status; 23 pub mod status;
23 pub mod sync; 24 pub mod sync;
src/release.rs
Old New
@@ -137,10 +137,10 @@ fn validate_remote_path(path: &str) -> Result<(), Error> {
137 /// The candidate ssh command strings, in the order they were read from their 137 /// The candidate ssh command strings, in the order they were read from their
138 /// respective sources. Named fields (rather than positional `Option<String>` 138 /// respective sources. Named fields (rather than positional `Option<String>`
139 /// args) so a call site can't silently pass them in the wrong order. 139 /// args) so a call site can't silently pass them in the wrong order.
140 struct SshCommandSources { 140 pub(crate) struct SshCommandSources {
141 collab_env: Option<String>, 141 pub(crate) collab_env: Option<String>,
142 git_env: Option<String>, 142 pub(crate) git_env: Option<String>,
143 config: Option<String>, 143 pub(crate) config: Option<String>,
144 } 144 }
145 145
146 /// Pick the ssh command string to use, first match wins: `collab_env` 146 /// Pick the ssh command string to use, first match wins: `collab_env`
@@ -150,7 +150,7 @@ struct SshCommandSources {
150 /// overridden when the GIT_SSH_COMMAND environment variable is set". A 150 /// overridden when the GIT_SSH_COMMAND environment variable is set". A
151 /// candidate that is empty or whitespace-only is treated as absent rather 151 /// candidate that is empty or whitespace-only is treated as absent rather
152 /// than winning with a blank program name. 152 /// than winning with a blank program name.
153 fn resolve_ssh_command(sources: SshCommandSources) -> String { 153 pub(crate) fn resolve_ssh_command(sources: SshCommandSources) -> String {
154 [sources.collab_env, sources.git_env, sources.config] 154 [sources.collab_env, sources.git_env, sources.config]
155 .into_iter() 155 .into_iter()
156 .flatten() 156 .flatten()
src/ssh_share.rs
Old New
@@ -0,0 +1,122 @@
1 //! One SSH connection for the whole of a sync.
2 //!
3 //! A sync runs `git fetch` and then `git push`, two separate processes that
4 //! each open their own SSH connection: two handshakes, two authentications,
5 //! two of whatever the network charges. Nothing between them needs the
6 //! remote, so the push can ride the connection the fetch already opened.
7 //!
8 //! OpenSSH's ControlMaster does exactly that. The first `ssh` becomes a
9 //! master that stays up after its client exits (`ControlPersist`), later
10 //! invocations with the same `ControlPath` multiplex over it, and `ssh -O
11 //! exit` tears it down. [`SharedSsh`] appends those options to whatever ssh
12 //! command git would run anyway, so a user's own `GIT_SSH_COMMAND` or
13 //! `core.sshCommand` (their key, their known-hosts policy) still applies.
14
15 use std::path::PathBuf;
16 use std::process::Command;
17
18 use git2::Repository;
19
20 use crate::release::{resolve_ssh_command, SshCommandSources};
21
22 /// A control-socket directory whose masters are closed on drop.
23 pub struct SharedSsh {
24 dir: PathBuf,
25 ssh_command: String,
26 }
27
28 impl SharedSsh {
29 /// Start sharing for the commands `apply` is later called on. `None` when
30 /// sharing cannot safely be set up, in which case each command opens its
31 /// own connection as before:
32 ///
33 /// - git would not run OpenSSH at all (`GIT_SSH` names a program and
34 /// nothing overrides it, or the configured command is not `ssh`), since
35 /// the options below are OpenSSH's;
36 /// - the socket directory cannot be created.
37 ///
38 /// Non-Unix platforms never share: control sockets are a Unix feature.
39 pub fn start(repo: &Repository) -> Option<SharedSsh> {
40 if !cfg!(unix) {
41 return None;
42 }
43 let git_env = std::env::var("GIT_SSH_COMMAND").ok();
44 if git_env.is_none() && std::env::var_os("GIT_SSH").is_some() {
45 return None;
46 }
47 let base = resolve_ssh_command(SshCommandSources {
48 collab_env: std::env::var("GIT_COLLAB_SSH_COMMAND").ok(),
49 git_env,
50 config: repo
51 .config()
52 .ok()
53 .and_then(|cfg| cfg.get_string("core.sshCommand").ok()),
54 });
55 let program = base.split_whitespace().next()?;
56 if std::path::Path::new(program).file_name()? != "ssh" {
57 return None;
58 }
59
60 let dir = socket_dir(&std::env::temp_dir(), std::process::id())?;
61 std::fs::create_dir_all(&dir).ok()?;
62 // `%C` is a fixed-length hash of host, port and user, so the path
63 // stays inside the socket length limit whatever the remote is named.
64 let ssh_command = format!(
65 "{base} -o ControlMaster=auto -o ControlPath={} -o ControlPersist=60",
66 dir.join("%C").display()
67 );
68 Some(SharedSsh { dir, ssh_command })
69 }
70
71 /// Point a git command at the shared connection.
72 pub fn apply(&self, cmd: &mut Command) {
73 cmd.env("GIT_SSH_COMMAND", &self.ssh_command);
74 }
75 }
76
77 /// A Unix socket path is limited to about 104 bytes on the platforms that
78 /// have them. Anything longer makes ssh fail outright, which would turn a
79 /// long `TMPDIR` into a sync that cannot fetch at all.
80 const SOCKET_PATH_LIMIT: usize = 100;
81
82 /// Where the control sockets go, or `None` when a socket under it could not
83 /// be opened. `%C` expands to a 40-character hash.
84 fn socket_dir(tmp: &std::path::Path, pid: u32) -> Option<PathBuf> {
85 let dir = tmp.join(format!("git-collab-ssh-{pid}"));
86 let socket_len = dir.as_os_str().len() + "/".len() + 40;
87 (socket_len <= SOCKET_PATH_LIMIT).then_some(dir)
88 }
89
90 impl Drop for SharedSsh {
91 fn drop(&mut self) {
92 // `-O exit` needs only the socket; the host argument is never
93 // resolved, it merely satisfies ssh's argument parsing.
94 if let Ok(entries) = std::fs::read_dir(&self.dir) {
95 for entry in entries.flatten() {
96 let _ = Command::new("ssh")
97 .args(["-O", "exit", "-o"])
98 .arg(format!("ControlPath={}", entry.path().display()))
99 .arg("git-collab-shared-connection")
100 .output();
101 }
102 }
103 let _ = std::fs::remove_dir_all(&self.dir);
104 }
105 }
106
107 #[cfg(test)]
108 mod tests {
109 use super::*;
110
111 #[test]
112 fn a_socket_dir_that_would_overflow_the_path_limit_is_refused() {
113 let long = std::path::Path::new("/").join("x".repeat(120));
114 assert!(socket_dir(&long, 1).is_none());
115 }
116
117 #[test]
118 fn a_short_socket_dir_is_accepted() {
119 let dir = socket_dir(std::path::Path::new("/tmp"), 42).unwrap();
120 assert_eq!(dir, std::path::Path::new("/tmp/git-collab-ssh-42"));
121 }
122 }
src/sync.rs
Old New
@@ -9,6 +9,7 @@ use crate::dag;
9 use crate::error::Error; 9 use crate::error::Error;
10 use crate::identity::get_author; 10 use crate::identity::get_author;
11 use crate::signing; 11 use crate::signing;
12 use crate::ssh_share::SharedSsh;
12 use crate::state; 13 use crate::state;
13 use crate::sync_lock::SyncLock; 14 use crate::sync_lock::SyncLock;
14 use crate::trust; 15 use crate::trust;
@@ -160,7 +161,12 @@ impl SyncState {
160 /// 161 ///
161 /// On success, all refs are marked as Pushed. On failure, falls back to 162 /// On success, all refs are marked as Pushed. On failure, falls back to
162 /// per-ref pushes to determine which specific refs failed. 163 /// per-ref pushes to determine which specific refs failed.
163 fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec<RefPushResult> { 164 fn push_refs_batched(
165 workdir: &Path,
166 remote_name: &str,
167 refs: &[String],
168 ssh: Option<&SharedSsh>,
169 ) -> Vec<RefPushResult> {
164 if refs.is_empty() { 170 if refs.is_empty() {
165 return Vec::new(); 171 return Vec::new();
166 } 172 }
@@ -169,11 +175,12 @@ fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec<
169 let mut args = vec!["push", remote_name]; 175 let mut args = vec!["push", remote_name];
170 args.extend(refspecs.iter().map(|s| s.as_str())); 176 args.extend(refspecs.iter().map(|s| s.as_str()));
171 177
172 match Command::new("git") 178 let mut cmd = Command::new("git");
173 .args(&args) 179 cmd.args(&args).current_dir(workdir);
174 .current_dir(workdir) 180 if let Some(ssh) = ssh {
175 .output() 181 ssh.apply(&mut cmd);
176 { 182 }
183 match cmd.output() {
177 Ok(output) if output.status.success() => { 184 Ok(output) if output.status.success() => {
178 // All refs pushed successfully 185 // All refs pushed successfully
179 refs.iter() 186 refs.iter()
@@ -187,20 +194,27 @@ fn push_refs_batched(workdir: &Path, remote_name: &str, refs: &[String]) -> Vec<
187 Ok(_) | Err(_) => { 194 Ok(_) | Err(_) => {
188 // Batch failed — retry each ref individually to isolate failures 195 // Batch failed — retry each ref individually to isolate failures
189 refs.iter() 196 refs.iter()
190 .map(|ref_name| push_ref_single(workdir, remote_name, ref_name)) 197 .map(|ref_name| push_ref_single(workdir, remote_name, ref_name, ssh))
191 .collect() 198 .collect()
192 } 199 }
193 } 200 }
194 } 201 }
195 202
196 /// Push a single ref to the remote. Used as a fallback when batched push fails. 203 /// Push a single ref to the remote. Used as a fallback when batched push fails.
197 fn push_ref_single(workdir: &Path, remote_name: &str, ref_name: &str) -> RefPushResult { 204 fn push_ref_single(
205 workdir: &Path,
206 remote_name: &str,
207 ref_name: &str,
208 ssh: Option<&SharedSsh>,
209 ) -> RefPushResult {
198 let refspec = format!("{}:{}", ref_name, ref_name); 210 let refspec = format!("{}:{}", ref_name, ref_name);
199 match Command::new("git") 211 let mut cmd = Command::new("git");
200 .args(["push", remote_name, &refspec]) 212 cmd.args(["push", remote_name, &refspec])
201 .current_dir(workdir) 213 .current_dir(workdir);
202 .output() 214 if let Some(ssh) = ssh {
203 { 215 ssh.apply(&mut cmd);
216 }
217 match cmd.output() {
204 Ok(output) => { 218 Ok(output) => {
205 if output.status.success() { 219 if output.status.success() {
206 RefPushResult { 220 RefPushResult {
@@ -712,9 +726,13 @@ fn sync_remote(
712 726
713 let author = get_author(repo)?; 727 let author = get_author(repo)?;
714 728
729 // One SSH connection for the fetch and the push both; see `ssh_share`.
730 let shared_ssh = SharedSsh::start(repo);
731
715 // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.) 732 // Step 1: Fetch collab refs using system git (handles SSH agent, credentials, etc.)
716 outln!("Fetching from '{}'...", remote_name); 733 outln!("Fetching from '{}'...", remote_name);
717 let fetch_status = Command::new("git") 734 let mut fetch = Command::new("git");
735 fetch
718 .args([ 736 .args([
719 "fetch", 737 "fetch",
720 remote_name, 738 remote_name,
@@ -723,7 +741,11 @@ fn sync_remote(
723 "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*", 741 "+refs/collab/archive/issues/*:refs/collab/sync/archive/issues/*",
724 "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*", 742 "+refs/collab/archive/patches/*:refs/collab/sync/archive/patches/*",
725 ]) 743 ])
726 .current_dir(&workdir) 744 .current_dir(&workdir);
745 if let Some(ssh) = &shared_ssh {
746 ssh.apply(&mut fetch);
747 }
748 let fetch_status = fetch
727 .status() 749 .status()
728 .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?; 750 .map_err(|e| Error::Cmd(format!("failed to run git fetch: {}", e)))?;
729 751
@@ -759,7 +781,7 @@ fn sync_remote(
759 if refs_to_push.is_empty() { 781 if refs_to_push.is_empty() {
760 outln!("Nothing to push."); 782 outln!("Nothing to push.");
761 } else { 783 } else {
762 let sync_result = push_refs(&workdir, remote_name, &refs_to_push); 784 let sync_result = push_refs(&workdir, remote_name, &refs_to_push, shared_ssh.as_ref());
763 785
764 if !sync_result.is_complete() { 786 if !sync_result.is_complete() {
765 // Save state for resume 787 // Save state for resume
@@ -829,7 +851,7 @@ fn sync_resume(
829 851
830 // Push the pending refs 852 // Push the pending refs
831 let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect(); 853 let ref_names: Vec<String> = state.pending_refs.iter().map(|(r, _)| r.clone()).collect();
832 let sync_result = push_refs(workdir, remote_name, &ref_names); 854 let sync_result = push_refs(workdir, remote_name, &ref_names, None);
833 855
834 if sync_result.is_complete() { 856 if sync_result.is_complete() {
835 // All pending refs pushed successfully 857 // All pending refs pushed successfully
@@ -876,8 +898,13 @@ fn sync_resume(
876 } 898 }
877 899
878 /// Push all refs in a single batch, printing per-ref status, and return aggregated results. 900 /// Push all refs in a single batch, printing per-ref status, and return aggregated results.
879 fn push_refs(workdir: &Path, remote_name: &str, refs: &[String]) -> SyncResult { 901 fn push_refs(
880 let results = push_refs_batched(workdir, remote_name, refs); 902 workdir: &Path,
903 remote_name: &str,
904 refs: &[String],
905 ssh: Option<&SharedSsh>,
906 ) -> SyncResult {
907 let results = push_refs_batched(workdir, remote_name, refs, ssh);
881 for result in &results { 908 for result in &results {
882 match &result.status { 909 match &result.status {
883 PushStatus::Pushed => outln!(" Pushed {}", result.ref_name), 910 PushStatus::Pushed => outln!(" Pushed {}", result.ref_name),
tests/sync_ssh_connection_test.rs
Old New
@@ -0,0 +1,50 @@
1 mod common;
2
3 use common::ServerHarness;
4
5 /// A sync fetches and then pushes. Run as two independent `git` invocations
6 /// those are two SSH connections, each paying a handshake, an auth round and
7 /// whatever the network charges. The push should ride the connection the
8 /// fetch already authenticated, so the server sees one login per sync.
9 #[test]
10 fn a_sync_authenticates_to_the_server_once() {
11 let harness = ServerHarness::new("one-login");
12 let key = harness.ssh_client_key();
13 harness.push_head();
14
15 let repo = harness.work_repo();
16 let url = harness.ssh_url_for(harness.repo_name());
17 repo.git(&["remote", "add", "srv", &url]);
18 repo.git(&[
19 "config",
20 "--add",
21 "remote.srv.fetch",
22 "+refs/collab/*:refs/collab/sync/srv/*",
23 ]);
24 repo.git(&["config", "collab.autoSync", "false"]);
25 repo.issue_open("Counted once");
26
27 let ssh = format!(
28 "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
29 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5",
30 key.display()
31 );
32 let output = repo
33 .cli_command()
34 .args(["sync", "--remote", "srv"])
35 .env("GIT_SSH_COMMAND", &ssh)
36 .output()
37 .expect("failed to run git-collab sync");
38 assert!(
39 output.status.success(),
40 "sync failed:\n{}",
41 String::from_utf8_lossy(&output.stderr)
42 );
43
44 let log = harness.server_log();
45 let logins = log.matches("Public key auth accepted").count();
46 assert_eq!(
47 logins, 1,
48 "one sync should log in once, saw {logins} logins:\n{log}"
49 );
50 }