docs/specs/2026-09-23-build-log-follow-design.md

main
gitbay/docs/specs/2026-09-23-build-log-follow-design.md rendered · source · history · blame · raw

150 lines · 6850 bytes

8 symbols in this file
  1# Following a running build
  2
  3Closes #250. `build log <owner/name> <n> --follow` streams a build's log
  4until the build reaches an outcome, and the web build page streams the
  5same command without JavaScript.
  6
  7## Problem
  8
  9No surface can follow a running build. `build log` prints what is stored
 10and exits; the build page renders the log once. Watching a build means
 11re-running the command or reloading the page.
 12
 13## Decision
 14
 15The capability is a flag on the existing control command. The web page
 16dispatches that command with a writer that escapes and flushes each
 17chunk, so the CLI, stock ssh and the page share one implementation.
 18
 19Rejected: a `Refresh` header on the build page. It re-renders the page
 20and re-reads the whole log per reload per viewer, interrupts selection
 21and screen readers, needs an off switch for WCAG 2.2.1, and gives only
 22the web a way to follow.
 23
 24## Store: waking followers
 25
 26`Store` gains an in-memory waiter table keyed by build id:
 27
 28- `BuildLogWait(id int64) <-chan struct{}` returns a channel closed by
 29  the next change to that build.
 30- `AppendBuildLog`, `FinishBuild` and `CancelBuild` close and drop the
 31  build's channel after their write succeeds (`wakeBuild(id)`).
 32- `BuildLogFrom(id, offset int64) (status string, chunk []byte, err
 33  error)` reads `status` and `substr(log, offset+1)` in one query, so a
 34  follower reads each byte once.
 35
 36gitbayd is one process and its SSH and HTTP servers share one
 37`*store.Store`, so an in-memory table reaches every follower. Writers in
 38another process do not wake anyone: `gitbayd admin` subcommands, and
 39every session under the system-sshd forced command (`gitbayd shell`),
 40where each session is its own process. The follow loop also re-reads
 41every 2 seconds, which bounds that case.
 42
 43## Command
 44
 45`build log <owner/name> <n> [--follow]`, still `ReadOnly`.
 46
 47Without `--follow`, unchanged.
 48
 49With `--follow`:
 50
 511. Write the stored log.
 522. Loop: take a wait channel, read from the offset, write any new bytes.
 53   If the status is no longer `pending` or `running` and the read
 54   returned nothing new, stop. Otherwise wait on the channel, the
 55   2-second timer, or `Ctx.Done`.
 563. Write `build <n> <status>` to stderr and exit 0, whatever the
 57   outcome. Stdout stays the log, byte for byte.
 58
 59The wait channel is taken before the read, so a change between the read
 60and the wait still wakes the loop.
 61
 62`Ctx` gains `Done <-chan struct{}`, nil when the surface has none. The
 63embedded sshd closes it when the session's channel closes (the CLI's
 64shared connection outlives a Ctrl-C, the channel does not); `gitbayd
 65shell` still passes nil, but its process does not end with the
 66session: OpenSSH closes the child's pipes and sends no signal to a
 67session with no pty, so a follow there ends at its next write, at the
 68build's outcome, or at the queued limit below; the per-account cap is
 69per process in that mode. httpd sets it from `r.Context()` on the web
 70and both API endpoints. A write error also ends the loop. On `Done`
 71the command returns `protocol.ExitFailure` with no message; nobody is
 72reading.
 73
 74At most 8 follows per account run at once (a counter in `control`,
 75decremented on return). The ninth exits 4: "8 follows are already open
 76for this account; close one and retry". Signed-out web viewers are
 77account 0 and share the 8; the ninth gets the stored log once with the
 78refusal under it.
 79
 80Nothing reaps a queued build (`ReapStaleBuilds` only reaps `running`
 81builds), and a running one is already bounded by the reaper's
 82deadline, so a follow of a build that stays `pending` ends on its own
 83after `followQueued` (10 minutes), writing to stderr `build <n> is
 84still queued; nothing claimed it in 10m0s. Follow again once a runner
 85has.` and exiting `protocol.ExitFailure`. The clock runs only while the
 86follow has seen the build `pending`; once it sees `running` or a
 87terminal status the limit no longer applies.
 88
 89The CLI's `pass("log", …)` help in `cmd/gitbay/main.go` names
 90`--follow`.
 91
 92## Web
 93
 94`GET /{owner}/{repo}/builds/{n}` streams when the build is `pending` or
 95`running`, the query has no `follow=0`, and the method is `GET`. A HEAD
 96request (the route also matches it) renders once, like `?follow=0`.
 97
 98Streaming:
 99
1001. Render `build.html` into a buffer with `Log` set to a marker and
101   `Live` true, and split the output at the marker.
1022. Write the head, flush.
1033. Dispatch `build log <repo> <n> --follow` with `Stdout` an escaping
104   writer (`template.HTMLEscape` per chunk, then flush through
105   `http.ResponseController`) and `Done` from the request context.
1064. If the request context is done (the client left), write nothing
107   more. Otherwise write the `</pre>` that begins the tail, then, by
108   the command's exit code: `ExitOK` reads the build and writes
109   `<p class="notice" role="status">build finished: <status></p>`;
110   `ExitDenied` (the follow cap) writes the stored log once above the
111   `</pre>` and an error paragraph with the refusal, except a
112   signed-out viewer (`viewer.ID == 0`) gets "Too many signed-out
113   viewers are watching live builds. This is the log so far; reload to
114   try again, or sign in." instead of the command's account-scoped
115   wording; `ExitFailure` with a message (the queued limit) writes it
116   as a `<p class="notice" role="status">`. Then the rest of the tail.
117
118`build.html`, when `Live`, puts a line above the log: the log streams
119until the build ends; a stream that stops with no "build finished" line
120resumes on reload; and a link to `?follow=0`, the same page rendered
121once, as the way to stop the updates (WCAG 2.2.2). The stored log
122renders inside the stream from the first write, so there is no separate
123"no log yet" state while live.
124
125`gzipWriter` gains `Flush()` (flush the gzip stream, then the underlying
126writer) and `Unwrap()`. The HTTP server has no `WriteTimeout`, so a long
127silent step does not end the response. The handler sets
128`X-Accel-Buffering: no` for a proxy in front of the instance.
129
130## API
131
132`/api/v1/cmd` and `/api/v1/read` buffer stdout, so there `--follow`
133returns the whole log when the build ends. Streaming a JSON response is
134out of scope.
135
136## Tests
137
138- `internal/store`: `BuildLogWait` closes on append, finish and cancel;
139  `BuildLogFrom` returns bytes past the offset and the status.
140- `internal/control`: follow a running build while another goroutine
141  appends and finishes; stdout is the full log, stderr ends with the
142  outcome, exit 0. Cancel ends a follow. A closed `Done` ends a follow.
143  The ninth concurrent follow exits 4.
144- `internal/httpd`: `gzipWriter` passes a flush through.
145- `e2e`: ssh `build log --follow` on a running build while the runner
146  key appends with `runner log` and reports with `runner done`; the web
147  page of a running build arrives complete with "build finished:
148  success"; `?follow=0` renders without the live line.
149
150Docs: the CI wiki page and Parity get the flag.