Commit c6e2981aef
c6e2981aef3674ab39ed62f1664b59366b059afe
parent: 9db5e874e1
Verified · cmc ci/build: success ci/test: success
cmc <hello@cleberg.net> · 2026-09-11 01:58 UTC
runner: bound dead ssh connections with ConnectTimeout and ServerAlive
Every ssh invocation gets ConnectTimeout=10, ServerAliveInterval=15 and
ServerAliveCountMax=3 after the identity and the operator's -ssh-opts,
so an operator's value wins. A claim whose TCP session died under it
hung the laptop runner's poll loop for thirteen hours.
cmd/gitbay-runner/config.go
+11
| @@ -96,3 +96,14 @@ func identityOpts(path string) []string { |
| 96 | 96 | } |
| 97 | 97 | return []string{"-F", "/dev/null", "-i", path, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=accept-new"} |
| 98 | 98 | } |
| 99 | |
| 100 | // sshOptions is every ssh invocation's option list: the identity, the |
| 101 | // operator's -ssh-opts, then a bound on dead connections. Without one a |
| 102 | // claim whose TCP session died under it (a laptop's network dropping) |
| 103 | // blocks the poll loop indefinitely; ssh took thirteen hours on one |
| 104 | // before a restart. ssh honours the first value of an option, so the |
| 105 | // operator's come first and override these. |
| 106 | func sshOptions(identity string, extra []string) []string { |
| 107 | opts := append(identityOpts(identity), extra...) |
| 108 | return append(opts, "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3") |
| 109 | } |
cmd/gitbay-runner/config_test.go
+17
| @@ -84,3 +84,20 @@ func TestIdentityOpts(t *testing.T) { |
| 84 | 84 | t.Fatalf("got %q, want %q", got, want) |
| 85 | 85 | } |
| 86 | 86 | } |
| 87 | |
| 88 | func TestSSHOptionsBoundDeadConnections(t *testing.T) { |
| 89 | got := strings.Join(sshOptions("/k", []string{"-o", "ServerAliveInterval=60"}), " ") |
| 90 | want := "-F /dev/null -i /k -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + |
| 91 | " -o ServerAliveInterval=60" + |
| 92 | " -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3" |
| 93 | if got != want { |
| 94 | t.Fatalf("got %q, want %q", got, want) |
| 95 | } |
| 96 | // No identity: the keepalive still applies, so a runner on its |
| 97 | // default key cannot hang on a dead connection either. |
| 98 | got = strings.Join(sshOptions("", nil), " ") |
| 99 | want = "-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3" |
| 100 | if got != want { |
| 101 | t.Fatalf("got %q, want %q", got, want) |
| 102 | } |
| 103 | } |
cmd/gitbay-runner/main.go
+1 −1
| @@ -152,7 +152,7 @@ func main() { |
| 152 | 152 | } |
| 153 | 153 | // Clipped: the later appends run from concurrent workers, and spare |
| 154 | 154 | // capacity here would have them writing the same backing array. |
| 155 | | r.sshOpts = slices.Clip(append(identityOpts(*identity), r.sshOpts...)) |
| 155 | r.sshOpts = slices.Clip(sshOptions(*identity, r.sshOpts)) |
| 156 | 156 | r.untrusted = *untrusted |
| 157 | 157 | for _, name := range strings.Split(*repos, ",") { |
| 158 | 158 | if name = strings.TrimSpace(name); name != "" { |