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"
48 } 48 }
49 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) 49 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
50 50
51 wait := func(port int) { 51 waitForPort(t, httpsPort)
52 t.Helper() 52 waitForPort(t, acmeHTTPPort)
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)
68 53
69 // The helper listener redirects everything to the canonical HTTPS host. 54 // The helper listener redirects everything to the canonical HTTPS host.
70 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { 55 client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
@@ -89,7 +74,7 @@ acme_http_addr = "127.0.0.1:%d"
89 conn.Close() 74 conn.Close()
90 t.Fatal("handshake unexpectedly succeeded with no CA reachable") 75 t.Fatal("handshake unexpectedly succeeded with no CA reachable")
91 } 76 }
92 wait(httpsPort) // still listening after the failed handshake 77 waitForPort(t, httpsPort) // still listening after the failed handshake
93 78
94 // Certificates cache under the server root. 79 // Certificates cache under the server root.
95 if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil { 80 if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil {
e2e/lfs_test.go +5 −9
@@ -7,7 +7,6 @@ import (
7 "encoding/hex" 7 "encoding/hex"
8 "encoding/json" 8 "encoding/json"
9 "fmt" 9 "fmt"
10 "net"
11 "net/http" 10 "net/http"
12 "os" 11 "os"
13 "os/exec" 12 "os/exec"
@@ -17,17 +16,14 @@ import (
17 "time" 16 "time"
18) 17)
19 18
19// waitForPort waits for a restarted daemon's listener; the failure names
20// the port.
20func waitForPort(t *testing.T, port int) { 21func waitForPort(t *testing.T, port int) {
21 t.Helper() 22 t.Helper()
22 deadline := time.Now().Add(10 * time.Second) 23 deadline := time.Now().Add(startupWait)
23 for { 24 for !dialable(port) {
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 }
29 if time.Now().After(deadline) { 25 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)
31 } 27 }
32 time.Sleep(50 * time.Millisecond) 28 time.Sleep(50 * time.Millisecond)
33 } 29 }
e2e/notify_test.go +1 −13
@@ -2,7 +2,6 @@ package e2e
2 2
3import ( 3import (
4 "fmt" 4 "fmt"
5 "net"
6 "os" 5 "os"
7 "os/exec" 6 "os/exec"
8 "strings" 7 "strings"
@@ -53,18 +52,7 @@ func TestActivityNotifications(t *testing.T) {
53 t.Fatal(err) 52 t.Fatal(err)
54 } 53 }
55 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) 54 t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() })
56 deadline := time.Now().Add(10 * time.Second) 55 waitForPort(t, inst.port)
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 }
68 56
69 aliceKey := inst.newKey(t, "alice") 57 aliceKey := inst.newKey(t, "alice")
70 bobKey := inst.newKey(t, "bob") 58 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
4import ( 4import (
5 "encoding/json" 5 "encoding/json"
6 "fmt" 6 "fmt"
7 "io"
7 "math/rand/v2" 8 "math/rand/v2"
8 "net" 9 "net"
9 "os" 10 "os"
@@ -23,8 +24,9 @@ type instance struct {
23 httpPort int 24 httpPort int
24 gitPort int 25 gitPort int
25 proc *exec.Cmd 26 proc *exec.Cmd
26 sshDir string // per-user client keys live here 27 sshDir string // per-user client keys live here
27 keyFile string // server.secret_key_file, outside root 28 keyFile string // server.secret_key_file, outside root
29 stderr *tailBuffer // the end of the first serve's stderr
28} 30}
29 31
30// nextPort hands out candidate ports. Seeded randomly so two test processes 32// nextPort hands out candidate ports. Seeded randomly so two test processes
@@ -113,8 +115,9 @@ port = %d
113 115
114 inst.admin(t, "admin", "secrets", "init") 116 inst.admin(t, "admin", "secrets", "init")
115 117
118 inst.stderr = &tailBuffer{max: 16 << 10}
116 inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve") 119 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)
118 if err := inst.proc.Start(); err != nil { 121 if err := inst.proc.Start(); err != nil {
119 t.Fatal(err) 122 t.Fatal(err)
120 } 123 }
@@ -126,9 +129,7 @@ port = %d
126 // Every listener, not just SSH: the HTTP and git ones come up in their 129 // Every listener, not just SSH: the HTTP and git ones come up in their
127 // own goroutines, and a test whose first act is an HTTP request used 130 // own goroutines, and a test whose first act is an HTTP request used
128 // to race them and be refused. 131 // to race them and be refused.
129 for _, port := range []int{inst.port, inst.httpPort, inst.gitPort} { 132 inst.waitListening(t, inst.port, inst.httpPort, inst.gitPort)
130 waitForPort(t, port)
131 }
132 return inst 133 return inst
133} 134}
134 135