Commit 65f453dc9b

65f453dc9bd4da2eb9fee17025aa6b6e2d0160ae

parent: 631ccdc137

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-07 05:06 UTC

runner, deploy, wiki: drain on SIGTERM

The runner stops claiming on the first signal, finishes and reports the
build in flight, then exits; the unit gives it 50 minutes. A second
signal exits at once. A restart no longer leaves a build running on the
server with nothing executing.

Closes #179

Layout: unified · split

.gitbay/wiki/Admin.org +8
@@ -476,6 +476,14 @@ cannot call =newuidmap= and the runner refuses to start. That is a
476considered trade, explained in the file and in the Threat-Model; if you 476considered trade, explained in the file and in the Threat-Model; if you
477run with =-isolation none=, set it back to =yes=. 477run with =-isolation none=, set it back to =yes=.
478 478
479*Restarting the runner is safe.* On SIGTERM it stops claiming, finishes
480the build in flight, reports it, and exits; the drop-in's
481=TimeoutStopSec=50min= covers the longest build. So =make deploy-runner=
482waits for a running build rather than orphaning it, and a build's result
483is retried for half a minute if gitbayd is restarting at that moment. A
484second SIGTERM ends the runner at once, abandoning the build to the
485reaper.
486
479*Validate podman mode on a scratch repository before pointing the runner 487*Validate podman mode on a scratch repository before pointing the runner
480at real ones.* Every deploy that switched the whole instance to 488at real ones.* Every deploy that switched the whole instance to
481containers and failed took CI down with it. Instead: create a throwaway 489containers and failed took CI down with it. Instead: create a throwaway
cmd/gitbay-runner/env_test.go +46
@@ -4,6 +4,7 @@ import (
4 "os" 4 "os"
5 "strings" 5 "strings"
6 "testing" 6 "testing"
7 "time"
7) 8)
8 9
9// A step's environment is constructed, not inherited: repository content 10// A step's environment is constructed, not inherited: repository content
@@ -129,3 +130,48 @@ func TestLimitArgs(t *testing.T) {
129 t.Errorf("limitArgs = %v, want %v", got, want) 130 t.Errorf("limitArgs = %v, want %v", got, want)
130 } 131 }
131} 132}
133
134// Closing stop drains: the build in flight finishes and is reported, and
135// no further build is claimed (#179).
136func TestServeDrainsOnStop(t *testing.T) {
137 stop := make(chan struct{})
138 started := make(chan struct{})
139 release := make(chan struct{})
140 calls := 0
141 r := &runner{stepFn: func() (bool, error) {
142 calls++
143 if calls == 1 {
144 close(started)
145 <-release // the build is in flight while stop closes
146 }
147 return true, nil
148 }}
149 done := make(chan struct{})
150 go func() { r.serve(1, false, time.Millisecond, stop); close(done) }()
151 <-started
152 close(stop)
153 close(release)
154 select {
155 case <-done:
156 case <-time.After(2 * time.Second):
157 t.Fatal("serve did not return after the in-flight build finished")
158 }
159 if calls != 1 {
160 t.Errorf("claimed %d builds after stop, want the one already in flight", calls-1)
161 }
162}
163
164// An idle worker leaves promptly on stop rather than sleeping out a poll.
165func TestServeStopsWhileIdle(t *testing.T) {
166 stop := make(chan struct{})
167 r := &runner{stepFn: func() (bool, error) { return false, nil }}
168 done := make(chan struct{})
169 go func() { r.serve(1, false, time.Hour, stop); close(done) }()
170 time.Sleep(20 * time.Millisecond)
171 close(stop)
172 select {
173 case <-done:
174 case <-time.After(2 * time.Second):
175 t.Fatal("idle worker did not stop")
176 }
177}
cmd/gitbay-runner/main.go +41 −8
@@ -17,6 +17,7 @@ import (
17 "log" 17 "log"
18 "os" 18 "os"
19 "os/exec" 19 "os/exec"
20 "os/signal"
20 "path/filepath" 21 "path/filepath"
21 "strings" 22 "strings"
22 "sync" 23 "sync"
@@ -52,6 +53,8 @@ type runner struct {
52 // memory and cpus cap one build's container; empty means no cap. 53 // memory and cpus cap one build's container; empty means no cap.
53 memory string 54 memory string
54 cpus string 55 cpus string
56 // stepFn is step, replaceable by tests.
57 stepFn func() (bool, error)
55 // repos limits which repositories this runner claims builds for. Empty 58 // repos limits which repositories this runner claims builds for. Empty
56 // means any, which is what a runner on the server itself wants; a runner 59 // means any, which is what a runner on the server itself wants; a runner
57 // somewhere that should not execute every repository's steps names them. 60 // somewhere that should not execute every repository's steps names them.
@@ -133,28 +136,58 @@ func main() {
133 // claiming at once is already safe; the runner just never used that. 136 // claiming at once is already safe; the runner just never used that.
134 // Each build works in its own build-<id> directory, so they do not 137 // Each build works in its own build-<id> directory, so they do not
135 // meet on disk either. 138 // meet on disk either.
139 // A stop signal drains: no build is claimed after it, and each build
140 // already in flight runs to completion and is reported. The old
141 // behaviour was to die mid-build, which left the build "running" on
142 // the server with nothing executing (#179). The unit's
143 // TimeoutStopSec bounds the drain; a second signal ends it now.
144 stop := make(chan struct{})
145 go func() {
146 sigs := make(chan os.Signal, 2)
147 signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
148 <-sigs
149 log.Printf("draining: finishing builds in flight, claiming no more")
150 close(stop)
151 <-sigs
152 log.Printf("second signal: exiting without draining")
153 os.Exit(1)
154 }()
155 r.serve(n, *once, *poll, stop)
156}
157
158// serve runs n workers until stop closes. A worker checks stop only
159// between builds, so closing it never interrupts one.
160func (r *runner) serve(n int, once bool, poll time.Duration, stop <-chan struct{}) {
161 if r.stepFn == nil {
162 r.stepFn = r.step
163 }
136 var wg sync.WaitGroup 164 var wg sync.WaitGroup
137 for i := 0; i < n; i++ { 165 for i := 0; i < n; i++ {
138 wg.Add(1) 166 wg.Add(1)
139 go func(i int) { 167 go func(i int) {
140 defer wg.Done() 168 defer wg.Done()
141 // Spread the idle polls across the interval rather than
142 // having every worker wake together: n workers asking the
143 // same question in the same instant is n times the load for
144 // one answer.
145 if n > 1 { 169 if n > 1 {
146 time.Sleep(time.Duration(i) * *poll / time.Duration(n)) 170 time.Sleep(time.Duration(i) * poll / time.Duration(n))
147 } 171 }
148 for { 172 for {
149 ran, err := r.step() 173 select {
174 case <-stop:
175 return
176 default:
177 }
178 ran, err := r.stepFn()
150 if err != nil { 179 if err != nil {
151 log.Printf("runner: %v", err) 180 log.Printf("runner: %v", err)
152 } 181 }
153 if *once { 182 if once {
154 return 183 return
155 } 184 }
156 if !ran { 185 if !ran {
157 time.Sleep(*poll) 186 select {
187 case <-stop:
188 return
189 case <-time.After(poll):
190 }
158 } 191 }
159 } 192 }
160 }(i) 193 }(i)
deploy/gitbay-runner.override.conf +5
@@ -44,6 +44,11 @@
44# start joins its namespaces — including a /tmp that no longer exists. 44# start joins its namespaces — including a /tmp that no longer exists.
45# End it with the service. 45# End it with the service.
46ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit 46ExecStopPost=-/usr/bin/pkill -u ci-runner -x catatonit
47# On stop the runner drains: it claims nothing more and finishes the
48# build in flight, then exits. Give it long enough — the per-build limit
49# is 45m plus half a minute of report retries — before systemd kills it.
50# `make deploy-runner` therefore waits for a running build (#179).
51TimeoutStopSec=50min
47ExecStart= 52ExecStart=
48ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 53ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3
49Nice=10 54Nice=10