Commit 6042b09dfc

6042b09dfc6e272c80fe885ed1c2f0045a716123

parent: 8c7024c0f4

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

cmc <hello@cleberg.net> · 2026-09-02 04:57 UTC

runner: a step is a process group, and ending it ends its children

Killing a step killed its shell and nothing else. On the CI host, where
/bin/sh is dash, "sh -c 'sleep 120'" forks sleep rather than exec'ing
it, so the kill left sleep alive holding the log pipe, and the wait on
that pipe lasted the full two minutes. On macOS the shell execs a
single command, which is why the cancel test passed locally and failed
on the host twice, once with a twenty-second budget and once with
sixty.

Each step now starts in its own process group and a cancel or timeout
kills the group. The wait after a kill is capped at ten seconds besides,
so a straggler that escaped the group cannot hold the build open. The
test's step forks its sleep as a child, the way dash runs everything, so
it fails without the group kill on any host.

Ref #90

Layout: unified · split

cmd/gitbay-runner/main.go +13 −4
@@ -204,11 +204,22 @@ func (r *runner) run(j job) bool {
204204 return false, "cancelled"
205205 default:
206206 }
207 ownProcessGroup(cmd)
207208 if err := cmd.Start(); err != nil {
208209 return false, fmt.Sprintf("start: %v", err)
209210 }
210211 done := make(chan error, 1)
211212 go func() { done <- cmd.Wait() }()
213 // After a kill, Wait returns once every holder of the log pipe is
214 // gone; the group kill makes that prompt, and the cap makes sure a
215 // straggler cannot hold the build open.
216 reap := func() {
217 killTree(cmd)
218 select {
219 case <-done:
220 case <-time.After(10 * time.Second):
221 }
222 }
212223 select {
213224 case err := <-done:
214225 if err != nil {
@@ -216,12 +227,10 @@ func (r *runner) run(j job) bool {
216227 }
217228 return true, ""
218229 case <-cancelled:
219 cmd.Process.Kill()
220 <-done
230 reap()
221231 return false, "cancelled"
222232 case <-time.After(time.Until(deadline)):
223 cmd.Process.Kill()
224 <-done
233 reap()
225234 return false, fmt.Sprintf("build timed out after %s", r.timeout)
226235 }
227236 }
cmd/gitbay-runner/proc_other.go added +13
@@ -0,0 +1,13 @@
1//go:build !unix
2
3package main
4
5import "os/exec"
6
7func ownProcessGroup(cmd *exec.Cmd) {}
8
9func killTree(cmd *exec.Cmd) {
10 if cmd.Process != nil {
11 cmd.Process.Kill()
12 }
13}
cmd/gitbay-runner/proc_unix.go added +26
@@ -0,0 +1,26 @@
1//go:build unix
2
3package main
4
5import (
6 "os/exec"
7 "syscall"
8)
9
10// A step runs as its own process group, so ending it ends everything it
11// started. Killing only the shell leaves its children alive and holding
12// the log pipe, and a wait on that pipe lasts as long as the longest
13// child: dash forks a single command rather than exec'ing it, so on a
14// Debian host "sh -c 'sleep 120'" survived its shell by two minutes.
15func ownProcessGroup(cmd *exec.Cmd) {
16 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
17}
18
19func killTree(cmd *exec.Cmd) {
20 if cmd.Process == nil {
21 return
22 }
23 if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
24 cmd.Process.Kill()
25 }
26}
e2e/build_cancel_test.go +3 −1
@@ -105,7 +105,7 @@ func TestBuildCancelRunning(t *testing.T) {
105105 mustGit(t, work, env, "clone", inst.sshURL("alice/slow"), "w")
106106 dir := filepath.Join(work, "w")
107107 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
108 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n slow:\n steps:\n - echo starting\n - sleep 120\n - echo never\n"), 0o644)
108 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte("jobs:\n slow:\n steps:\n - echo starting\n - sleep 120 & wait\n - echo never\n"), 0o644)
109109 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
110110 mustGit(t, dir, env, "add", ".")
111111 mustGit(t, dir, env, "commit", "-q", "-m", "slow")
@@ -117,6 +117,8 @@ func TestBuildCancelRunning(t *testing.T) {
117117 }
118118
119119 // A real runner, in the background, claims the build and sits in sleep.
120 // The step forks sleep as a child of the shell, the way dash runs
121 // every command, so the cancel must reach past the shell to end it.
120122 opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
121123 inst.port, runnerKey, filepath.Join(inst.sshDir, "known_hosts"))
122124 runner := exec.Command(inst.runner, "-once", "-remote", "git@127.0.0.1", "-ssh-opts", opts,