Files
mesh-controller/internal/builder/kill.go
T
jochen e610f2d92c Let a build agent be paused, have a build killed, and end an ask cancelled as it took it (hq ADR 0219)
A queued ask could only be waited out and a running build only ended by
stopping the machine, which redelivered it elsewhere. The holder now serves
current, kill, pause and resume on its own machine's subjects; a kill ends
the build's process group and labelled containers and settles the ask as
failed, killed by hand; pause is kept in the workspace across a restart and
said on the bus. The controller writes cancelled ids to a cancelled set the
holder reads on taking an ask, closing the race a delete alone leaves.
2026-10-05 19:17:42 +02:00

79 lines
3.0 KiB
Go

package builder
import (
"context"
"os/exec"
"strings"
"syscall"
"time"
)
// A build killed by hand (novox/hq ADR 0219).
//
// A build is a tree of commands — git, then docker, and docker's own children — and a container the
// docker client started keeps running when the client dies. So ending one by hand is three things:
// the build's context is cancelled, which kills each command's whole process group rather than the
// one process the context knows; every container the build started carries the build's id as a
// label, so what outlived its client is found and removed by that label; and the holder announces
// the outcome itself, since nothing else will.
// BuildLabel is the label every container a build starts carries, valued with the build's id.
const BuildLabel = "mesh.build"
// KillWait is how long a command killed with its build may take to let go of its output before it
// is abandoned: a grandchild holding the pipe open must not hold the build open with it.
const KillWait = 10 * time.Second
// inItsOwnGroup makes a command the leader of its own process group, killed as a group when its
// context ends.
func inItsOwnGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return nil
}
// The negative pid is the group: the command and everything it started.
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil {
return cmd.Process.Kill()
}
return nil
}
cmd.WaitDelay = KillWait
}
// Labelled is a Runner that marks every container a build starts with the build's id, so a kill
// finds what outlived the docker client (novox/hq ADR 0219). Applied at the one place every command
// passes rather than at each `docker run` the builder composes: a run added later is labelled too.
func Labelled(run Runner, id string) Runner {
return func(ctx context.Context, dir string, name string, args ...string) (string, error) {
return run(ctx, dir, name, LabelledArgs(name, args, id)...)
}
}
// LabelledArgs is a command's arguments with the build's label added, when it is a `docker run`.
func LabelledArgs(name string, args []string, id string) []string {
if name != "docker" || len(args) == 0 || args[0] != "run" || id == "" {
return args
}
out := make([]string, 0, len(args)+2)
out = append(out, "run", "--label", BuildLabel+"="+id)
return append(out, args[1:]...)
}
// RemoveContainersOf removes every container labelled with the build's id, running or not, and
// says how many. Run with a context of its own: the build's is the one that was just cancelled.
func RemoveContainersOf(ctx context.Context, run Runner, id string) (int, error) {
out, err := run(ctx, "", "docker", "ps", "-aq", "--filter", "label="+BuildLabel+"="+id)
if err != nil {
return 0, err
}
ids := strings.Fields(out)
if len(ids) == 0 {
return 0, nil
}
if _, err := run(ctx, "", "docker", append([]string{"rm", "-f"}, ids...)...); err != nil {
return 0, err
}
return len(ids), nil
}