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 | 49 | t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) |
| 50 | 50 | |
| 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) |
| 68 | 53 | |
| 69 | 54 | // The helper listener redirects everything to the canonical HTTPS host. |
| 70 | 55 | client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { |
| @@ -89,7 +74,7 @@ acme_http_addr = "127.0.0.1:%d" |
| 89 | 74 | conn.Close() |
| 90 | 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 | 79 | // Certificates cache under the server root. |
| 95 | 80 | if _, err := os.Stat(filepath.Join(inst.root, "acme")); err != nil { |
e2e/lfs_test.go
+5 −9
| @@ -7,7 +7,6 @@ import ( |
| 7 | 7 | "encoding/hex" |
| 8 | 8 | "encoding/json" |
| 9 | 9 | "fmt" |
| 10 | | "net" |
| 11 | 10 | "net/http" |
| 12 | 11 | "os" |
| 13 | 12 | "os/exec" |
| @@ -17,17 +16,14 @@ import ( |
| 17 | 16 | "time" |
| 18 | 17 | ) |
| 19 | 18 | |
| 19 | // waitForPort waits for a restarted daemon's listener; the failure names |
| 20 | // the port. |
| 20 | 21 | func waitForPort(t *testing.T, port int) { |
| 21 | 22 | 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) { |
| 29 | 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 | 28 | time.Sleep(50 * time.Millisecond) |
| 33 | 29 | } |
e2e/notify_test.go
+1 −13
| @@ -2,7 +2,6 @@ package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| 5 | | "net" |
| 6 | 5 | "os" |
| 7 | 6 | "os/exec" |
| 8 | 7 | "strings" |
| @@ -53,18 +52,7 @@ func TestActivityNotifications(t *testing.T) { |
| 53 | 52 | t.Fatal(err) |
| 54 | 53 | } |
| 55 | 54 | 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) |
| 68 | 56 | |
| 69 | 57 | aliceKey := inst.newKey(t, "alice") |
| 70 | 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 | 4 | import ( |
| 5 | 5 | "encoding/json" |
| 6 | 6 | "fmt" |
| 7 | "io" |
| 7 | 8 | "math/rand/v2" |
| 8 | 9 | "net" |
| 9 | 10 | "os" |
| @@ -23,8 +24,9 @@ type instance struct { |
| 23 | 24 | httpPort int |
| 24 | 25 | gitPort int |
| 25 | 26 | 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 |
| 28 | 30 | } |
| 29 | 31 | |
| 30 | 32 | // nextPort hands out candidate ports. Seeded randomly so two test processes |
| @@ -113,8 +115,9 @@ port = %d |
| 113 | 115 | |
| 114 | 116 | inst.admin(t, "admin", "secrets", "init") |
| 115 | 117 | |
| 118 | inst.stderr = &tailBuffer{max: 16 << 10} |
| 116 | 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 | 121 | if err := inst.proc.Start(); err != nil { |
| 119 | 122 | t.Fatal(err) |
| 120 | 123 | } |
| @@ -126,9 +129,7 @@ port = %d |
| 126 | 129 | // Every listener, not just SSH: the HTTP and git ones come up in their |
| 127 | 130 | // own goroutines, and a test whose first act is an HTTP request used |
| 128 | 131 | // 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) |
| 132 | 133 | return inst |
| 133 | 134 | } |
| 134 | 135 | |