Commit 0d76166636

0d7616663688c2e7b40c8ae8aad65cc38af82c6d

parent: 7e29c13f48

Verified · cmc ci/build: success ci/test: failure

cmc <hello@cleberg.net> · 2026-09-28 23:24 UTC

e2e: wait up to 60s for a start, stop if gitbayd exits, show why

Closes #300

Layout: unified · split

e2e/acme_test.go +3 −18
@@ -48,23 +48,8 @@ acme_http_addr = "127.0.0.1:%d"
4848 }
4949 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
5050
51 wait := func(port int) {
52 t.Helper()
53 deadline := time.Now().Add(10 * time.Second)
54 for {
55 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
56 if err == nil {
57 conn.Close()
58 return
59 }
60 if time.Now().After(deadline) {
61 t.Fatalf("port %d never came up", port)
62 }
63 time.Sleep(50 * time.Millisecond)
64 }
65 }
66 wait(httpsPort)
67 wait(acmeHTTPPort)
51 waitForPort(t, httpsPort)
52 waitForPort(t, acmeHTTPPort)
6853
6954 // The helper listener redirects everything to the canonical HTTPS host.
7055 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -89,7 +74,7 @@ acme_http_addr = "127.0.0.1:%d"
8974 conn.Close()
9075 t.Fatal("handshake unexpectedly succeeded with no CA reachable")
9176 }
92 wait(httpsPort) // still listening after the failed handshake
77 waitForPort(t, httpsPort) // still listening after the failed handshake
9378
9479 // Certificates cache under the server root.
9580 if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil {
e2e/lfs_test.go +5 −9
@@ -7,7 +7,6 @@ import (
77 "encoding/hex"
88 "encoding/json"
99 "fmt"
10 "net"
1110 "net/http"
1211 "os"
1312 "os/exec"
@@ -17,17 +16,14 @@ import (
1716 "time"
1817)
1918
19// waitForPort waits for a restarted daemon's listener; the failure names
20// the port.
2021func waitForPort(t *testing.T, port int) {
2122 t.Helper()
22 deadline := time.Now().Add(10 * time.Second)
23 for {
24 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
25 if err == nil {
26 conn.Close()
27 return
28 }
23 deadline := time.Now().Add(startupWait)
24 for !dialable(port) {
2925 if time.Now().After(deadline) {
30 t.Fatal("listener did not come back")
26 t.Fatalf("listener on %d did not come back within %s", port, startupWait)
3127 }
3228 time.Sleep(50 * time.Millisecond)
3329 }
e2e/notify_test.go +1 −13
@@ -2,7 +2,6 @@ package e2e
22
33import (
44 "fmt"
5 "net"
65 "os"
76 "os/exec"
87 "strings"
@@ -53,18 +52,7 @@ func TestActivityNotifications(t *testing.T) {
5352 t.Fatal(err)
5453 }
5554 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
56 deadline := time.Now().Add(10 * time.Second)
57 for {
58 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond)
59 if err == nil {
60 conn.Close()
61 break
62 }
63 if time.Now().After(deadline) {
64 t.Fatal("daemon did not restart")
65 }
66 time.Sleep(50 * time.Millisecond)
67 }
55 waitForPort(t, inst.port)
6856
6957 aliceKey := inst.newKey(t, "alice")
7058 bobKey := inst.newKey(t, "bob")
e2e/ready_test.go added +106
@@ -0,0 +1,106 @@
1package e2e
2
3import (
4 "bytes"
5 "fmt"
6 "net"
7 "os"
8 "runtime"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13)
14
15// startupWait bounds how long a gitbayd start may take before its
16// listeners answer. CI runs these tests in parallel on a four-core runner
17// beside other builds, where a start has taken longer than ten seconds;
18// the wait ends early if the daemon exits.
19const startupWait = 60 * time.Second
20
21// tailBuffer keeps the last max bytes written to it, for a failure message.
22type tailBuffer struct {
23 mu sync.Mutex
24 max int
25 buf []byte
26}
27
28func (b *tailBuffer) Write(p []byte) (int, error) {
29 b.mu.Lock()
30 defer b.mu.Unlock()
31 b.buf = append(b.buf, p...)
32 if over := len(b.buf) - b.max; over > 0 {
33 b.buf = b.buf[over:]
34 }
35 return len(p), nil
36}
37
38func (b *tailBuffer) String() string {
39 b.mu.Lock()
40 defer b.mu.Unlock()
41 return string(bytes.TrimSpace(b.buf))
42}
43
44// exited reports whether the process has ended without reaping it, so the
45// test can still Wait on it. It reads /proc on Linux, where CI runs, and
46// reports false elsewhere.
47func exited(pid int) bool {
48 if runtime.GOOS != "linux" {
49 return false
50 }
51 stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))
52 if err != nil {
53 return true
54 }
55 // The state follows the command name, which is in parentheses.
56 s := string(stat)
57 if i := strings.LastIndexByte(s, ')'); i >= 0 && i+2 < len(s) {
58 return s[i+2] == 'Z'
59 }
60 return false
61}
62
63func dialable(port int) bool {
64 conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
65 if err != nil {
66 return false
67 }
68 conn.Close()
69 return true
70}
71
72// waitListening waits until every port accepts a connection. It fails at
73// once if the instance's daemon exits, and on either failure names the
74// ports that never answered and shows the end of the daemon's stderr.
75func (i *instance) waitListening(t *testing.T, ports ...int) {
76 t.Helper()
77 deadline := time.Now().Add(startupWait)
78 pending := ports
79 for {
80 var still []int
81 for _, p := range pending {
82 if !dialable(p) {
83 still = append(still, p)
84 }
85 }
86 if len(still) == 0 {
87 return
88 }
89 pending = still
90 why := ""
91 switch {
92 case i.proc != nil && i.proc.Process != nil && exited(i.proc.Process.Pid):
93 why = "gitbayd exited"
94 case time.Now().After(deadline):
95 why = fmt.Sprintf("gitbayd still not listening after %s", startupWait)
96 }
97 if why != "" {
98 log := "(not captured)"
99 if i.stderr != nil {
100 log = i.stderr.String()
101 }
102 t.Fatalf("%s on %v; its stderr ends:\n%s", why, pending, log)
103 }
104 time.Sleep(50 * time.Millisecond)
105 }
106}
e2e/ssh_test.go +7 −6
@@ -4,6 +4,7 @@ package e2e
44import (
55 "encoding/json"
66 "fmt"
7 "io"
78 "math/rand/v2"
89 "net"
910 "os"
@@ -23,8 +24,9 @@ type instance struct {
2324 httpPort int
2425 gitPort int
2526 proc *exec.Cmd
26 sshDir string // per-user client keys live here
27 keyFile string // server.secret_key_file, outside root
27 sshDir string // per-user client keys live here
28 keyFile string // server.secret_key_file, outside root
29 stderr *tailBuffer // the end of the first serve's stderr
2830}
2931
3032// nextPort hands out candidate ports. Seeded randomly so two test processes
@@ -113,8 +115,9 @@ port = %d
113115
114116 inst.admin(t, "admin", "secrets", "init")
115117
118 inst.stderr = &tailBuffer{max: 16 << 10}
116119 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve")
117 inst.proc.Stderr = os.Stderr
120 inst.proc.Stderr = io.MultiWriter(os.Stderr, inst.stderr)
118121 if err := inst.proc.Start(); err != nil {
119122 t.Fatal(err)
120123 }
@@ -126,9 +129,7 @@ port = %d
126129 // Every listener, not just SSH: the HTTP and git ones come up in their
127130 // own goroutines, and a test whose first act is an HTTP request used
128131 // to race them and be refused.
129 for _, port := range []int{inst.port, inst.httpPort, inst.gitPort} {
130 waitForPort(t, port)
131 }
132 inst.waitListening(t, inst.port, inst.httpPort, inst.gitPort)
132133 return inst
133134}
134135