Commit f84f577ef1
Verified · cmc
docs/specs/2026-09-23-build-log-follow-design.md added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | # Following a running build | |
| 2 | ||
| 3 | Closes #250. `build log <owner/name> <n> --follow` streams a build's log | |
| 4 | until the build reaches an outcome, and the web build page streams the | |
| 5 | same command without JavaScript. | |
| 6 | ||
| 7 | ## Problem | |
| 8 | ||
| 9 | No surface can follow a running build. `build log` prints what is stored | |
| 10 | and exits; the build page renders the log once. Watching a build means | |
| 11 | re-running the command or reloading the page. | |
| 12 | ||
| 13 | ## Decision | |
| 14 | ||
| 15 | The capability is a flag on the existing control command. The web page | |
| 16 | dispatches that command with a writer that escapes and flushes each | |
| 17 | chunk, so the CLI, stock ssh and the page share one implementation. | |
| 18 | ||
| 19 | Rejected: a `Refresh` header on the build page. It re-renders the page | |
| 20 | and re-reads the whole log per reload per viewer, interrupts selection | |
| 21 | and screen readers, needs an off switch for WCAG 2.2.1, and gives only | |
| 22 | the 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 | ||
| 36 | gitbayd is one process and its SSH and HTTP servers share one | |
| 37 | `*store.Store`, so an in-memory table reaches every follower. Writers in | |
| 38 | another process (`gitbayd admin` subcommands) do not wake anyone; the | |
| 39 | follow loop also re-reads every 10 seconds, which bounds that case. | |
| 40 | ||
| 41 | ## Command | |
| 42 | ||
| 43 | `build log <owner/name> <n> [--follow]`, still `ReadOnly`. | |
| 44 | ||
| 45 | Without `--follow`, unchanged. | |
| 46 | ||
| 47 | With `--follow`: | |
| 48 | ||
| 49 | 1. Write the stored log. | |
| 50 | 2. Loop: take a wait channel, read from the offset, write any new bytes. | |
| 51 | If the status is no longer `pending` or `running` and the read | |
| 52 | returned nothing new, stop. Otherwise wait on the channel, the | |
| 53 | 10-second timer, or `Ctx.Done`. | |
| 54 | 3. Write `build <n> <status>` to stderr and exit 0, whatever the | |
| 55 | outcome. Stdout stays the log, byte for byte. | |
| 56 | ||
| 57 | The wait channel is taken before the read, so a change between the read | |
| 58 | and the wait still wakes the loop. | |
| 59 | ||
| 60 | `Ctx` gains `Done <-chan struct{}`, nil when the surface has none. sshd | |
| 61 | sets it from the session's context and httpd from `r.Context()`. A write | |
| 62 | error also ends the loop. On `Done` the command returns | |
| 63 | `protocol.ExitFailure` with no message; nobody is reading. | |
| 64 | ||
| 65 | At most 8 follows per account run at once (a counter in `control`, | |
| 66 | decremented on return). The ninth exits 4: "8 follows are already open | |
| 67 | for this account; close one and retry". | |
| 68 | ||
| 69 | The CLI's `pass("log", …)` help in `cmd/gitbay/main.go` names | |
| 70 | `--follow`. | |
| 71 | ||
| 72 | ## Web | |
| 73 | ||
| 74 | `GET /{owner}/{repo}/builds/{n}` streams when the build is `pending` or | |
| 75 | `running` and the query has no `follow=0`. Otherwise it renders as now. | |
| 76 | ||
| 77 | Streaming: | |
| 78 | ||
| 79 | 1. Render `build.html` into a buffer with `Log` set to a marker and | |
| 80 | `Live` true, and split the output at the marker. | |
| 81 | 2. Write the head, flush. | |
| 82 | 3. Dispatch `build log <repo> <n> --follow` with `Stdout` an escaping | |
| 83 | writer (`template.HTMLEscape` per chunk, then flush through | |
| 84 | `http.ResponseController`) and `Done` from the request context. | |
| 85 | 4. After the command returns, read the build and write | |
| 86 | `<p class="notice" role="status">build finished: <status></p>` after | |
| 87 | the `</pre>` that begins the tail, then the rest of the tail. A | |
| 88 | dropped connection writes nothing more. | |
| 89 | ||
| 90 | `build.html`, when `Live`, puts a line above the log: the log streams | |
| 91 | until the build ends; a stream that stops with no "build finished" line | |
| 92 | resumes on reload; and a link to `?follow=0`, the same page rendered | |
| 93 | once, as the way to stop the updates (WCAG 2.2.2). The stored log | |
| 94 | renders inside the stream from the first write, so there is no separate | |
| 95 | "no log yet" state while live. | |
| 96 | ||
| 97 | `gzipWriter` gains `Flush()` (flush the gzip stream, then the underlying | |
| 98 | writer) and `Unwrap()`. The HTTP server has no `WriteTimeout`, so a long | |
| 99 | silent step does not end the response. The handler sets | |
| 100 | `X-Accel-Buffering: no` for a proxy in front of the instance. | |
| 101 | ||
| 102 | ## API | |
| 103 | ||
| 104 | `/api/v1/cmd` and `/api/v1/read` buffer stdout, so there `--follow` | |
| 105 | returns the whole log when the build ends. Streaming a JSON response is | |
| 106 | out of scope. | |
| 107 | ||
| 108 | ## Tests | |
| 109 | ||
| 110 | - `internal/store`: `BuildLogWait` closes on append, finish and cancel; | |
| 111 | `BuildLogFrom` returns bytes past the offset and the status. | |
| 112 | - `internal/control`: follow a running build while another goroutine | |
| 113 | appends and finishes; stdout is the full log, stderr ends with the | |
| 114 | outcome, exit 0. Cancel ends a follow. A closed `Done` ends a follow. | |
| 115 | The ninth concurrent follow exits 4. | |
| 116 | - `internal/httpd`: `gzipWriter` passes a flush through. | |
| 117 | - `e2e`: ssh `build log --follow` on a running build while the runner | |
| 118 | key appends with `runner log` and reports with `runner done`; the web | |
| 119 | page of a running build arrives complete with "build finished: | |
| 120 | success"; `?follow=0` renders without the live line. | |
| 121 | ||
| 122 | Docs: the CI wiki page and Parity get the flag. | |