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 {
9696 }
9797 return []string{"-F", "/dev/null", "-i", path, "-o", "IdentitiesOnly=yes", "-o", "StrictHostKeyChecking=accept-new"}
9898}
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.
106func 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) {
8484 t.Fatalf("got %q, want %q", got, want)
8585 }
8686}
87
88func 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() {
152152 }
153153 // Clipped: the later appends run from concurrent workers, and spare
154154 // 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))
156156 r.untrusted = *untrusted
157157 for _, name := range strings.Split(*repos, ",") {
158158 if name = strings.TrimSpace(name); name != "" {