Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/leasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,20 @@
builtin = false # turn the built-in pack off
```

A rule can also match `exe` names (globs, `.exe` optional), a launcher `parent` (with
`parent_cmdline`, a regex that launcher's own command line must match too), and exclude with
A rule can also match `paths` (a list of plain executable paths, one per
line, no regex, compared exactly against the process's image path), `exe`
names (globs, `.exe` optional), a launcher `parent` (with `parent_cmdline`,
a regex that launcher's own command line must match too), and exclude with
`not_cmdline`. The built-in Steam rule matches Steam's `reaper` only when it runs as
`reaper SteamLaunch AppId=...`, so the REAPER DAW, also named `reaper`, holds nothing.
- **A changed file applies without a restart.** berthd stats berth.toml once a
second: a changed file is re-validated, and its claim rules, the brokers'
scheduling fields (priority order, juggle window, squat allow list, the
park budget) and the engine list apply at once. A file that fails to
validate keeps the applied config and says so on the event stream
(`config.rejected`); a good one notes `config.reloaded`. Start-up facts
need a restart: the scan and poll intervals, the metrics listener, the
suite's model roots, and the gateway's own copy of the config.
- **`berth hold [--for 2h] [--reason game]`** takes the card for you. It is a pinned lease at
the top priority, so nothing drains it, and it ends on Ctrl-C, after `--for`, or when the
process dies. On a node with several cards it takes the first one. `--gpu gpu1` holds another
Expand Down
39 changes: 39 additions & 0 deletions internal/broker/liveconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright 2026 Trevin Tindall (T-Crypt)
// SPDX-License-Identifier: Apache-2.0

package broker

import "time"

// LiveConfig is what a config reload may change on a running broker: the
// scheduling fields berth.toml names. Identity and wiring (card, events,
// files, readers) are start-up facts, and the flag values (reserve, waits,
// grace) are the process's own: neither moves.
type LiveConfig struct {
Order []string
JuggleWindow time.Duration
Allow []string
Comfy []ComfyEngine
ParkFloor uint64
ParkMax uint64
ParkTTL time.Duration
ParkResidualMax uint64
ParkPressure float64
}

// SetLiveConfig applies a reloaded config's scheduling fields. A drain or a
// park that started under the old rules runs out under them; new decisions
// use the new ones.
func (b *Broker) SetLiveConfig(l LiveConfig) {
b.mu.Lock()
defer b.mu.Unlock()
b.cfg.Order = l.Order
b.cfg.JuggleWindow = l.JuggleWindow
b.cfg.Allow = l.Allow
b.cfg.Comfy = l.Comfy
b.cfg.ParkFloor = l.ParkFloor
b.cfg.ParkMax = l.ParkMax
b.cfg.ParkTTL = l.ParkTTL
b.cfg.ParkResidualMax = l.ParkResidualMax
b.cfg.ParkPressure = l.ParkPressure
}
31 changes: 31 additions & 0 deletions internal/broker/liveconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright 2026 Trevin Tindall (T-Crypt)
// SPDX-License-Identifier: Apache-2.0

package broker

import (
"testing"
"time"
)

// SetLiveConfig moves the scheduling fields a decision reads; identity and
// the process's own flag values stay where they are.
func TestSetLiveConfigUpdatesSchedulingFieldsOnly(t *testing.T) {
b := &Broker{cfg: Config{
Card: "gpu0", Reserve: 42, DefaultWait: time.Minute,
Order: []string{"a"}, JuggleWindow: 0,
}}
b.SetLiveConfig(LiveConfig{
Order: []string{"b"}, JuggleWindow: 9 * time.Minute,
Allow: []string{"xorg"}, ParkFloor: 1024, ParkMax: 2048,
ParkTTL: time.Hour, ParkResidualMax: 512, ParkPressure: 0.5,
})
if b.cfg.Order[0] != "b" || b.cfg.JuggleWindow != 9*time.Minute || b.cfg.Allow[0] != "xorg" ||
b.cfg.ParkFloor != 1024 || b.cfg.ParkMax != 2048 || b.cfg.ParkTTL != time.Hour ||
b.cfg.ParkResidualMax != 512 || b.cfg.ParkPressure != 0.5 {
t.Fatalf("scheduling fields = %+v", b.cfg)
}
if b.cfg.Card != "gpu0" || b.cfg.Reserve != 42 || b.cfg.DefaultWait != time.Minute {
t.Fatalf("identity or flag values moved: %+v", b.cfg)
}
}
38 changes: 33 additions & 5 deletions internal/claims/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,11 @@ const (

// Rule is one [[claim]] entry, or a built-in.
type Rule struct {
Name string // shown on the lease: "claim:<name>"
Exe []string // globs on the process name (comm) or argv[0]'s base name; case-insensitive
Name string // shown on the lease: "claim:<name>"
Exe []string // globs on the process name (comm) or argv[0]'s base name; case-insensitive
// Paths are plain executable paths, one per line, no regex: a process
// whose image path equals one of them (case-insensitively) matches.
Paths []string
Cmdline string // regex on the full command line (on Windows: the executable's full path)
Not string // regex on the same; a match here excludes the process (a launcher beside its games)
Parent []string // globs on any ancestor's name: the launcher (Steam's reaper, lutris-wrapper)
Expand Down Expand Up @@ -71,7 +74,8 @@ type Proc struct {
Start uint64
Name string // comm
Argv0 string // base name of argv[0]
Cmdline string // argv joined with spaces
Cmdline string // argv joined with spaces (on Windows: the executable's full path)
Exe string // the executable's full image path, "" where it cannot be read
Parent int
}

Expand Down Expand Up @@ -125,8 +129,14 @@ func (r *Rule) Compile() error {
return fmt.Errorf("claim %q: %v", r.Name, err)
}
}
if len(r.Exe) == 0 && r.Cmdline == "" && len(r.Parent) == 0 {
return fmt.Errorf("claim %q: give exe, cmdline or parent to match on", r.Name)
if len(r.Exe) == 0 && len(r.Paths) == 0 && r.Cmdline == "" && len(r.Parent) == 0 {
return fmt.Errorf("claim %q: give exe, paths, cmdline or parent to match on", r.Name)
}
for i, p := range r.Paths {
r.Paths[i] = strings.TrimSpace(p)
if r.Paths[i] == "" {
return fmt.Errorf("claim %q: empty paths entry", r.Name)
}
}
for _, g := range append(append([]string{}, r.Exe...), r.Parent...) {
if _, err := path.Match(strings.ToLower(g), ""); err != nil {
Expand Down Expand Up @@ -169,6 +179,9 @@ func (r *Rule) Matches(p Proc, table map[int]Proc) bool {
if len(r.Exe) > 0 && !globAny(r.Exe, p.Name) && !globAny(r.Exe, p.Argv0) {
return false
}
if len(r.Paths) > 0 && !pathAny(r.Paths, p.Exe) {
return false
}
if r.cmdline != nil && !r.cmdline.MatchString(p.Cmdline) {
return false
}
Expand Down Expand Up @@ -198,6 +211,21 @@ func (r *Rule) Matches(p Proc, table map[int]Proc) bool {
return true
}

// pathAny reports whether the process's image path is one of the rule's
// plain paths, case-insensitively (a Windows path's case is cosmetic). A
// process whose image path is unknown matches no path.
func pathAny(paths []string, exe string) bool {
if exe == "" {
return false
}
for _, p := range paths {
if strings.EqualFold(p, exe) {
return true
}
}
return false
}

func globAny(globs []string, name string) bool {
if name == "" {
return false
Expand Down
80 changes: 80 additions & 0 deletions internal/claims/claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ func TestCompileRejects(t *testing.T) {
{Name: "x", Parent: []string{"reaper"}, ParentCmdline: "("},
{Name: "x", Exe: []string{"a"}, GPU: "card 1"},
{Name: "x", Exe: []string{"a"}, GPU: "gpu1", Action: ActionTrack},
{Name: "x", Paths: []string{" "}},
} {
if err := r.Compile(); err == nil {
t.Errorf("%+v compiled", r)
Expand Down Expand Up @@ -239,6 +240,42 @@ func TestWatcherHoldsTheCardsItNames(t *testing.T) {
}
}

// A plain-path rule names the game by its executable, no regex: the process
// whose image path it names matches, case-insensitively, and nothing else —
// not a sibling path, not the same name elsewhere, not an unreadable path.
func TestPathsRuleMatchesTheNamedImage(t *testing.T) {
rs := compiled(t, Rule{Name: "pbz",
Paths: []string{`D:\Games\!Offline Activation\Pub\PBZ\platform\steam\PBZ.exe`},
Action: ActionHold})
ps := []Proc{
{PID: 10, Name: "PBZ.exe", Cmdline: `D:\Games\!Offline Activation\Pub\PBZ\platform\steam\PBZ.exe`,
Exe: `D:\GAMES\!Offline Activation\Pub\PBZ\platform\steam\PBZ.exe`},
{PID: 20, Name: "PBZ.exe", Cmdline: `C:\temp\PBZ.exe`, Exe: `C:\temp\PBZ.exe`},
{PID: 30, Name: "PBZ.exe", Exe: `D:\Games\!Offline Activation\Pub\PBZ\platform\steam\PBZ.exe.old`},
{PID: 40, Name: "nopath.exe"},
}
m := Match(rs, ps)
if got := pids(m["pbz"]); len(got) != 1 || got[0] != 10 {
t.Fatalf("paths rule = %v, want [10]", got)
}
if len(m) != 1 {
t.Fatalf("unexpected matches: %v", m)
}
}

// Every matcher a rule gives must agree: a process whose image path the rule
// names still loses to a cmdline the process does not have.
func TestPathsAndsOtherMatchers(t *testing.T) {
rs := compiled(t, Rule{Name: "x", Paths: []string{`D:\Games\a.exe`}, Cmdline: `b\.exe`, Action: ActionHold})
m := Match(rs, []Proc{
{PID: 10, Exe: `D:\Games\a.exe`, Cmdline: `D:\Games\a.exe`},
{PID: 20, Exe: `D:\Games\a.exe`, Cmdline: `C:\tools\b.exe`},
})
if got := pids(m["x"]); len(got) != 1 || got[0] != 20 {
t.Fatalf("paths and cmdline must both agree: got %v, want [20]", got)
}
}

// Windows-shaped process tables: Name is the exe, Cmdline the image path.
func TestBuiltinWindows(t *testing.T) {
ps := []Proc{
Expand Down Expand Up @@ -277,3 +314,46 @@ func TestBuiltinWindows(t *testing.T) {
t.Errorf("unexpected matches: %v", m)
}
}

// A reload that removes a rule ends that rule's hold at once, and a rule the
// reload adds arms on the next scan.
func TestSetRulesCancelsGoneRulesAndArmsNewOnes(t *testing.T) {
fb := &fakeBroker{held: make(chan struct{}, 4)}
w := &Watcher{Cards: []Broker{fb}, Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
Scan: func() ([]Proc, error) { return steam, nil }, holds: map[string]*hold{}}
w.Rules = compiled(t, Builtin()[0]) // steam-game
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
t0 := time.Now()

w.Tick(ctx, t0)
select {
case <-fb.held:
case <-time.After(time.Second):
t.Fatal("no hold requested")
}

// Reload: steam-game goes, a plain-path rule for a running game arrives.
procs := []Proc{{PID: 900, Name: "RyGT7.exe", Exe: `D:\SteamLibrary\Gran Turismo\GT 7\RyGT7\RyGT7.exe`}}
w.Scan = func() ([]Proc, error) { return procs, nil }
w.SetRules(compiled(t, Rule{Name: "gt7",
Paths: []string{`D:\SteamLibrary\Gran Turismo\GT 7\RyGT7\RyGT7.exe`},
Action: ActionHold}))
if w.holds["steam-game"] != nil {
t.Fatal("a hold outlived its removed rule")
}
if len(fb.events) != 2 || fb.events[1] != proto.EvClaimEnded {
t.Fatalf("events = %v, want started then ended", fb.events)
}

// The new rule arms on the next scan.
w.Tick(ctx, t0.Add(time.Second))
select {
case <-fb.held:
case <-time.After(time.Second):
t.Fatal("no hold requested for the new rule")
}
if r := fb.asked[1]; r.Engine != "claim:gt7" {
t.Fatalf("second request = %+v", r)
}
}
6 changes: 6 additions & 0 deletions internal/claims/scan_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,11 @@ func readProc(pid int, buf []byte) (Proc, []byte, bool) {
p.Argv0 = filepath.Base(strings.ReplaceAll(args[0], `\`, "/")) // wine argv0 can be a Windows path
p.Cmdline = strings.Join(args, " ")
}
// The image path, for plain-path claim rules: /proc/pid/exe is a symlink
// to the binary. A process that exits mid-read, or whose binary was
// replaced, has none.
if target, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe"); err == nil {
p.Exe = target
}
return p, raw, true
}
1 change: 1 addition & 0 deletions internal/claims/scan_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func Scan() ([]Proc, error) {
p := Proc{PID: pid, Parent: int(e.ParentProcessID), Name: windows.UTF16ToString(e.ExeFile[:])}
p.Argv0 = p.Name
p.Cmdline = imagePath(pid)
p.Exe = p.Cmdline // on Windows the command line read is the image path
p.Start = sys.StartTime(pid)
out = append(out, p)
})
Expand Down
28 changes: 28 additions & 0 deletions internal/claims/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,34 @@ func (w *Watcher) onCard(states []proto.Claim, i int) []proto.Claim {
return out
}

// SetRules swaps in a reloaded rule set: a hold whose rule is gone ends at
// once (a hold without a rule is a hold outliving its reason), a surviving
// rule keeps its hold, and a rule the reload adds arms on the next scan.
func (w *Watcher) SetRules(rules []Rule) {
w.mu.Lock()
defer w.mu.Unlock()
for name, h := range w.holds {
if !ruleIn(rules, name) {
h.cancel()
delete(w.holds, name)
if len(w.Cards) > 0 {
w.Cards[0].Note(proto.Event{Kind: proto.EvClaimEnded, Engine: "claim:" + name,
Detail: "rule removed by a config reload"})
}
}
}
w.Rules = rules
}

func ruleIn(rules []Rule, name string) bool {
for i := range rules {
if rules[i].Name == name {
return true
}
}
return false
}

// granted is the lease IDs the hold has been granted so far, comma
// separated. Called with w.mu held.
func (h *hold) granted() string {
Expand Down
Loading