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. |
| 20 | func waitForPort(t *testing.T, port int) { |
21 | func 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 | |
| 3 | import ( |
3 | import ( |
| 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 @@ |
| |
1 | package e2e |
| |
2 | |
| |
3 | import ( |
| |
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. |
| |
19 | const startupWait = 60 * time.Second |
| |
20 | |
| |
21 | // tailBuffer keeps the last max bytes written to it, for a failure message. |
| |
22 | type tailBuffer struct { |
| |
23 | mu sync.Mutex |
| |
24 | max int |
| |
25 | buf []byte |
| |
26 | } |
| |
27 | |
| |
28 | func (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 | |
| |
38 | func (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. |
| |
47 | func 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 | |
| |
63 | func 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. |
| |
75 | func (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 |
| 4 | import ( |
4 | import ( |
| 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 | |