Commit eed104c810

eed104c810b42e9c86ac7a6de65e49d1062042b6

parent: ea2aad2524

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-23 05:43 UTC

build log --follow: stream a build's log until it ends

Ref #250
cmd/gitbay/main.go +1 −1
@@ -48,7 +48,7 @@ func newRoot() *cobra.Command {
4848 group("build", "CI builds",
4949 pass("list", "recent builds: <owner/name>", passOpts{server: []string{"build", "list"}, needsRepo: true}),
5050 pass("show", "one build: <owner/name> <n>", passOpts{server: []string{"build", "show"}, needsRepo: true}),
51 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
51 pass("log", "a build's log: <owner/name> <n> [--follow]", passOpts{server: []string{"build", "log"}, needsRepo: true}),
5252 pass("jobs", "list the jobs a trigger can name", passOpts{server: []string{"build", "jobs"}, needsRepo: true}),
5353 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
5454 pass("cancel", "withdraw a queued build: <n>", passOpts{server: []string{"build", "cancel"}, needsRepo: true}),
internal/control/build.go +10 −3
@@ -27,8 +27,8 @@ func init() {
2727 Summary: "show one build",
2828 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
2929 register(Command{Path: []string{"build", "log"},
30 Summary: "print a build's log",
31 Usage: "build log <owner/name> <n>", ReadOnly: true, Run: runBuildLog})
30 Summary: "print a build's log, or follow it until the build ends",
31 Usage: "build log <owner/name> <n> [--follow]", ReadOnly: true, Run: runBuildLog})
3232
3333 register(Command{Path: []string{"build", "jobs"},
3434 Summary: "list the jobs a trigger can name",
@@ -196,10 +196,17 @@ func runBuildShow(c *Ctx, args []string) int {
196196}
197197
198198func runBuildLog(c *Ctx, args []string) int {
199 _, b, code := buildRef(c, args)
199 f, err := parseFlags(args, flagSpec{Bools: []string{"--follow"}, MaxPos: 2, Usage: c.Cmd.Usage})
200 if err != nil {
201 return c.fail(protocol.ExitUsage, "%v", err)
202 }
203 _, b, code := buildRef(c, f.Pos)
200204 if code >= 0 {
201205 return code
202206 }
207 if f.Has("--follow") {
208 return followBuildLog(c, b)
209 }
203210 log, err := c.Store.BuildLog(b.ID)
204211 if err != nil {
205212 return c.fail(protocol.ExitFailure, "%v", err)
internal/control/build_test.go +5 −1
@@ -42,9 +42,13 @@ func gitRunner(t *testing.T) func(dir string, args ...string) string {
4242
4343// newQueueTestRepo returns a store with one public repo (default branch
4444// "main", matching the schema default) and the uid to queue builds as.
45//
46// A real file, not ":memory:": ":memory:" gives each connection its own
47// database, so a goroutine querying while another writes (build log
48// --follow) sees an empty schema (see internal/store/contention_test.go).
4549func newQueueTestRepo(t *testing.T) (*store.Store, store.Repo, int64) {
4650 t.Helper()
47 st, err := store.Open(":memory:")
51 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
4852 if err != nil {
4953 t.Fatal(err)
5054 }
internal/control/buildfollow.go added +95
@@ -0,0 +1,95 @@
1package control
2
3import (
4 "fmt"
5 "sync"
6 "time"
7
8 "gitbay.org/gitbay/internal/protocol"
9 "gitbay.org/gitbay/internal/store"
10)
11
12// maxFollows is how many build log follows one account holds open at
13// once. Signed-out web viewers are account 0 and share it.
14const maxFollows = 8
15
16var (
17 // followPoll bounds a wait with no wake. A write from another process
18 // (gitbayd admin, or any session under gitbayd shell) wakes nobody;
19 // this is how its bytes still arrive.
20 followPoll = 2 * time.Second
21 // followSettle is how long a follow keeps reading after the build has
22 // an outcome: a cancel appends its line after the status changes, and
23 // a cancelled runner's stream runs on until its next check.
24 followSettle = time.Second
25)
26
27var (
28 followMu sync.Mutex
29 follows = map[int64]int{}
30)
31
32func takeFollow(uid int64) bool {
33 followMu.Lock()
34 defer followMu.Unlock()
35 if follows[uid] >= maxFollows {
36 return false
37 }
38 follows[uid]++
39 return true
40}
41
42func dropFollow(uid int64) {
43 followMu.Lock()
44 defer followMu.Unlock()
45 if follows[uid]--; follows[uid] <= 0 {
46 delete(follows, uid)
47 }
48}
49
50// followBuildLog writes the build's log as it grows and returns once the
51// build has an outcome and its last bytes are written. The outcome goes
52// to stderr, so stdout is the log byte for byte.
53func followBuildLog(c *Ctx, b store.Build) int {
54 if !takeFollow(c.User.ID) {
55 return c.fail(protocol.ExitDenied, "%d follows are already open for this account; close one and retry", maxFollows)
56 }
57 defer dropFollow(c.User.ID)
58
59 var off int64
60 var settleBy time.Time
61 for {
62 wake := c.Store.BuildLogWait(b.ID)
63 status, chunk, err := c.Store.BuildLogFrom(b.ID, off)
64 if err != nil {
65 return c.fail(protocol.ExitFailure, "%v", err)
66 }
67 if len(chunk) > 0 {
68 if _, err := c.Stdout.Write(chunk); err != nil {
69 return protocol.ExitFailure
70 }
71 off += int64(len(chunk))
72 }
73 wait := followPoll
74 if status != "pending" && status != "running" {
75 if settleBy.IsZero() {
76 settleBy = time.Now().Add(followSettle)
77 }
78 left := time.Until(settleBy)
79 if left <= 0 && len(chunk) == 0 {
80 fmt.Fprintf(c.Stderr, "build %d %s\n", b.Number, status)
81 return protocol.ExitOK
82 }
83 wait = min(wait, max(left, 0))
84 }
85 t := time.NewTimer(wait)
86 select {
87 case <-wake:
88 case <-t.C:
89 case <-c.Done:
90 t.Stop()
91 return protocol.ExitFailure
92 }
93 t.Stop()
94 }
95}
internal/control/buildfollow_test.go added +138
@@ -0,0 +1,138 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// follow starts build log --follow on build 1 of repo and returns the
14// buffers and a channel carrying the exit code.
15func follow(t *testing.T, st *store.Store, uid int64, repo store.Repo, done <-chan struct{}) (*bytes.Buffer, *bytes.Buffer, chan int) {
16 t.Helper()
17 u, err := st.UserByID(uid)
18 if err != nil {
19 t.Fatal(err)
20 }
21 var out, errOut bytes.Buffer
22 c := &Ctx{User: u, Scope: "full", Store: st, Stdin: strings.NewReader(""),
23 Stdout: &out, Stderr: &errOut, Done: done}
24 res := make(chan int, 1)
25 go func() { res <- Dispatch(c, []string{"build", "log", repo.Path(), "1", "--follow"}) }()
26 return &out, &errOut, res
27}
28
29func waitExit(t *testing.T, res chan int) int {
30 t.Helper()
31 select {
32 case code := <-res:
33 return code
34 case <-time.After(10 * time.Second):
35 t.Fatal("follow did not end")
36 return -1
37 }
38}
39
40func shortFollowTimers(t *testing.T) {
41 settle, poll := followSettle, followPoll
42 followSettle, followPoll = 200*time.Millisecond, 50*time.Millisecond
43 t.Cleanup(func() { followSettle, followPoll = settle, poll })
44}
45
46// The follow prints the stored log, then what arrives, and ends with the
47// outcome on stderr once the build finishes.
48func TestBuildLogFollow(t *testing.T) {
49 shortFollowTimers(t)
50 st, repo, uid := newQueueTestRepo(t)
51 id, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["true"]`, "", "", true)
52 if err != nil {
53 t.Fatal(err)
54 }
55 st.AppendBuildLog(id, []byte("queued\n"))
56 out, errOut, res := follow(t, st, uid, repo, nil)
57
58 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
59 t.Fatalf("claim: %v %v", ok, err)
60 }
61 st.AppendBuildLog(id, []byte("step one\n"))
62 st.AppendBuildLog(id, []byte("step two\n"))
63 if err := st.FinishBuild(id, "success"); err != nil {
64 t.Fatal(err)
65 }
66 if code := waitExit(t, res); code != protocol.ExitOK {
67 t.Fatalf("exit %d: %s", code, errOut)
68 }
69 if got := out.String(); got != "queued\nstep one\nstep two\n" {
70 t.Errorf("stdout %q", got)
71 }
72 if got := strings.TrimSpace(errOut.String()); got != "build 1 success" {
73 t.Errorf("stderr %q", got)
74 }
75}
76
77// A cancel ends the follow, and the line the cancel appends after the
78// status change still arrives.
79func TestBuildLogFollowCancel(t *testing.T) {
80 shortFollowTimers(t)
81 st, repo, uid := newQueueTestRepo(t)
82 id, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["true"]`, "", "", true)
83 if err != nil {
84 t.Fatal(err)
85 }
86 out, errOut, res := follow(t, st, uid, repo, nil)
87 if err := st.CancelBuild(id); err != nil {
88 t.Fatal(err)
89 }
90 st.AppendBuildLog(id, []byte("cancelled by alice before a runner claimed it\n"))
91 if code := waitExit(t, res); code != protocol.ExitOK {
92 t.Fatalf("exit %d: %s", code, errOut)
93 }
94 if !strings.Contains(out.String(), "cancelled by alice") {
95 t.Errorf("the cancel line did not arrive: %q", out)
96 }
97 if got := strings.TrimSpace(errOut.String()); got != "build 1 cancelled" {
98 t.Errorf("stderr %q", got)
99 }
100}
101
102// Closing Done ends a follow of a build that is still running.
103func TestBuildLogFollowDone(t *testing.T) {
104 shortFollowTimers(t)
105 st, repo, uid := newQueueTestRepo(t)
106 if _, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["true"]`, "", "", true); err != nil {
107 t.Fatal(err)
108 }
109 done := make(chan struct{})
110 _, _, res := follow(t, st, uid, repo, done)
111 close(done)
112 if code := waitExit(t, res); code != protocol.ExitFailure {
113 t.Fatalf("exit %d, want %d", code, protocol.ExitFailure)
114 }
115}
116
117// An account holding maxFollows is refused another.
118func TestBuildLogFollowCap(t *testing.T) {
119 st, repo, uid := newQueueTestRepo(t)
120 if _, err := st.CreateBuild(repo.ID, "unit", "abc", "main", `["true"]`, "", "", true); err != nil {
121 t.Fatal(err)
122 }
123 followMu.Lock()
124 follows[uid] = maxFollows
125 followMu.Unlock()
126 t.Cleanup(func() {
127 followMu.Lock()
128 delete(follows, uid)
129 followMu.Unlock()
130 })
131 _, errOut, res := follow(t, st, uid, repo, nil)
132 if code := waitExit(t, res); code != protocol.ExitDenied {
133 t.Fatalf("exit %d, want %d", code, protocol.ExitDenied)
134 }
135 if !strings.Contains(errOut.String(), "8 follows are already open") {
136 t.Errorf("stderr %q", errOut)
137 }
138}
internal/control/control.go +4
@@ -40,6 +40,10 @@ type Ctx struct {
4040 // Cmd is the command being run, set by Dispatch, so a usage error can
4141 // print the registered usage rather than a copy of it.
4242 Cmd Command
43 // Done, when the surface has one, closes when nobody is reading any
44 // more: the SSH channel closed or the HTTP request ended. A command
45 // that runs until something happens (build log --follow) stops on it.
46 Done <-chan struct{}
4347}
4448
4549// usage reports a bad invocation with the command's registered usage,