diff --git a/docs/leasing.md b/docs/leasing.md index 11fa9d4..ceaddb6 100644 --- a/docs/leasing.md +++ b/docs/leasing.md @@ -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 diff --git a/internal/broker/liveconfig.go b/internal/broker/liveconfig.go new file mode 100644 index 0000000..0711547 --- /dev/null +++ b/internal/broker/liveconfig.go @@ -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 +} diff --git a/internal/broker/liveconfig_test.go b/internal/broker/liveconfig_test.go new file mode 100644 index 0000000..de75ebf --- /dev/null +++ b/internal/broker/liveconfig_test.go @@ -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) + } +} diff --git a/internal/claims/claims.go b/internal/claims/claims.go index 348f008..26e2bfd 100644 --- a/internal/claims/claims.go +++ b/internal/claims/claims.go @@ -41,8 +41,11 @@ const ( // Rule is one [[claim]] entry, or a built-in. type Rule struct { - Name string // shown on the lease: "claim:" - Exe []string // globs on the process name (comm) or argv[0]'s base name; case-insensitive + Name string // shown on the lease: "claim:" + 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) @@ -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 } @@ -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 { @@ -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 } @@ -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 diff --git a/internal/claims/claims_test.go b/internal/claims/claims_test.go index 1bd99dc..e5aeb1f 100644 --- a/internal/claims/claims_test.go +++ b/internal/claims/claims_test.go @@ -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) @@ -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{ @@ -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) + } +} diff --git a/internal/claims/scan_linux.go b/internal/claims/scan_linux.go index ff8887a..c33074d 100644 --- a/internal/claims/scan_linux.go +++ b/internal/claims/scan_linux.go @@ -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 } diff --git a/internal/claims/scan_windows.go b/internal/claims/scan_windows.go index 4073523..14b303e 100644 --- a/internal/claims/scan_windows.go +++ b/internal/claims/scan_windows.go @@ -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) }) diff --git a/internal/claims/watch.go b/internal/claims/watch.go index bff7cd7..301660d 100644 --- a/internal/claims/watch.go +++ b/internal/claims/watch.go @@ -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 { diff --git a/internal/cli/config_reload.go b/internal/cli/config_reload.go new file mode 100644 index 0000000..800bbcf --- /dev/null +++ b/internal/cli/config_reload.go @@ -0,0 +1,79 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "log/slog" + "os" + "time" + + "github.com/T-Crypt/berth/internal/config" + "github.com/T-Crypt/berth/internal/proto" +) + +// reloadEvery is how often the daemon asks whether berth.toml changed. The +// claim watcher's own 2 s scan sets the floor for how quickly a new rule can +// matter, so a second is plenty, and a stat loop needs no new dependency. +const reloadEvery = time.Second + +// configReloader applies a changed berth.toml to the running daemon through +// apply: the claim rules, the brokers' scheduling fields and the engine +// list. A file that fails to validate is left in place - the applied config +// keeps - and said so on the event stream. Start-up facts stay put and need +// a restart: the scan and poll intervals (their tickers were built at +// start-up), the metrics listener, and the gateway's own copy of the config. +type configReloader struct { + path string + merged func(string) (config.Config, []config.Warning, error) + apply func(cfg config.Config) string // returns the event detail + kind func(kind, detail string) // one event on the node's stream + log *slog.Logger + every time.Duration + lastMod time.Time + lastSize int64 +} + +// Run watches until ctx ends. A config that is absent at start-up is absent +// for good: the daemon runs on its flags, and a file appearing mid-run is a +// restart's job. +func (r *configReloader) Run(ctx context.Context) { + armed := false + if fi, err := os.Stat(r.path); err == nil { + r.lastMod, r.lastSize, armed = fi.ModTime(), fi.Size(), true + } + if !armed { + return + } + t := time.NewTicker(r.every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + fi, err := os.Stat(r.path) + if err != nil { + return // deleted or moved mid-run: stop watching, keep what applied + } + if fi.ModTime().Equal(r.lastMod) && fi.Size() == r.lastSize { + continue + } + cfg, warns, err := r.merged(r.path) + if err != nil { + r.lastMod, r.lastSize = fi.ModTime(), fi.Size() // do not re-reject the same file + r.kind(proto.EvConfigRejected, err.Error()) + r.log.Error("config reload rejected; the applied config keeps", "path", r.path, "err", err) + continue + } + for _, w := range warns { + r.log.Warn(w.Key + ": " + w.Message) + } + detail := r.apply(cfg) + r.lastMod, r.lastSize = fi.ModTime(), fi.Size() + r.kind(proto.EvConfigReloaded, detail) + r.log.Info("config reloaded", "path", r.path, "what", detail) + } +} diff --git a/internal/cli/config_reload_test.go b/internal/cli/config_reload_test.go new file mode 100644 index 0000000..cc35564 --- /dev/null +++ b/internal/cli/config_reload_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/T-Crypt/berth/internal/config" +) + +// A changed berth.toml applies once per change; a file that fails to +// validate keeps what applied and says so; a later good file applies again. +func TestConfigReloaderAppliesChangesAndKeepsOldOnReject(t *testing.T) { + path := filepath.Join(t.TempDir(), "berth.toml") + writeFile := func(body string) { + t.Helper() + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + writeFile("[[claim]]\nname = 'a'\npaths = ['/g/a']\n") + + var mu sync.Mutex + var applied []config.Config + var events [][2]string + rel := &configReloader{ + path: path, + merged: func(string) (config.Config, []config.Warning, error) { + c, err := config.Load(path) + return c, nil, err + }, + apply: func(cfg config.Config) string { + mu.Lock() + applied = append(applied, cfg) + mu.Unlock() + return "ok" + }, + kind: func(k, d string) { + mu.Lock() + events = append(events, [2]string{k, d}) + mu.Unlock() + }, + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + every: 10 * time.Millisecond, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go rel.Run(ctx) + + // Nothing changes until the file does. + time.Sleep(100 * time.Millisecond) + mu.Lock() + if len(applied) != 0 { + t.Fatalf("applied before a change: %d times", len(applied)) + } + mu.Unlock() + + writeFile("[[claim]]\nname = 'a'\npaths = ['/g/a', '/g/b']\n") + waitFor := func(n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + ok := len(applied) == n + mu.Unlock() + if ok { + return + } + time.Sleep(5 * time.Millisecond) + } + mu.Lock() + t.Fatalf("applied %d times, want %d", len(applied), n) + mu.Unlock() + } + waitFor(1) + + // A file that fails to validate keeps what applied. + writeFile("x = \n") + waitFor(1) + deadline := time.Now().Add(2 * time.Second) + for { + mu.Lock() + rejected := len(events) > 0 && events[len(events)-1][0] == "config.rejected" + mu.Unlock() + if rejected { + break + } + if time.Now().After(deadline) { + mu.Lock() + t.Fatalf("no rejection event: %v", events) + mu.Unlock() + } + time.Sleep(5 * time.Millisecond) + } + + // A later good file applies again. + writeFile("[[claim]]\nname = 'a'\npaths = ['/g/c']\n") + waitFor(2) + mu.Lock() + kinds := make([]string, len(events)) + for i, e := range events { + kinds[i] = e[0] + } + mu.Unlock() + if len(kinds) != 3 || kinds[0] != "config.reloaded" || kinds[1] != "config.rejected" || kinds[2] != "config.reloaded" { + t.Fatalf("events = %v, want reloaded, rejected, reloaded", kinds) + } +} diff --git a/internal/cli/daemon.go b/internal/cli/daemon.go index 16188dc..12aaa0b 100644 --- a/internal/cli/daemon.go +++ b/internal/cli/daemon.go @@ -236,9 +236,10 @@ func runDaemon(c *Context) int { } // Claims: games and apps Berth does not run hold the card or have their // memory named, from the process table. Validate already compiled them. + var cw *claims.Watcher if rules, err := cfg.ClaimRules(); err == nil && len(rules) > 0 { claimCards(rules, n, log) - cw := &claims.Watcher{Rules: rules, Every: cfg.Claims.Every.Duration, Cards: brokers, Log: log} + cw = &claims.Watcher{Rules: rules, Every: cfg.Claims.Every.Duration, Cards: brokers, Log: log} go cw.Run(ctx) hold, track := 0, 0 for _, r := range rules { @@ -259,6 +260,61 @@ func runDaemon(c *Context) int { log.Info("discovering engines", "every", engines.DiscoveryEvery, "hosts", cfg.Engines.Hosts, "extra_ports", cfg.Engines.Ports) } } + // berth.toml applies without a restart: a changed file re-arms the claim + // rules, the brokers' scheduling fields and the engine list. A file that + // fails to validate keeps the applied config and says so on the event + // stream. Start-up facts stay put: the scan and poll intervals, the + // metrics listener, the suite's model roots, and the gateway's own copy. + if *cfgPath != "" { + squatterAllow := func(cfg config.Config) []string { + l := cfg.Squatters.Allow + if *allow != "" { + l = splitList(*allow) + } + return l + } + rel := &configReloader{ + path: *cfgPath, + merged: suite.Merged, + apply: func(cfg config.Config) string { + nrules := 0 + if rules, err := cfg.ClaimRules(); err == nil { + nrules = len(rules) + claimCards(rules, n, log) // a reloaded rule's gpu resolves like at start-up + if cw == nil { // a file that names claims for the first time arms them + if nrules > 0 { + cw = &claims.Watcher{Rules: rules, Every: cfg.Claims.Every.Duration, Cards: brokers, Log: log} + go cw.Run(ctx) + } + } else { + cw.SetRules(rules) // a reload that removes every rule ends every hold + } + } + live := broker.LiveConfig{ + Order: cfg.Priority.Order, + JuggleWindow: cfg.Priority.JuggleWindow.Duration, + Allow: squatterAllow(cfg), + Comfy: comfyEngines(cfg), + ParkFloor: parkFloorBytes(cfg, *headroomMib), + ParkMax: parkMaxBytes(cfg), + ParkTTL: cfg.ParkTTL(), + ParkResidualMax: cfg.ParkResidualMax(), + ParkPressure: cfg.ParkPressure(), + } + for _, cd := range cards { + cd.B.SetLiveConfig(live) + } + if watcher != nil { + watcher.SetEngines(cfg.Engine) + } + return fmt.Sprintf("engines %d, claim rules %d", len(cfg.Engine), nrules) + }, + kind: func(k, d string) { first.Note(proto.Event{Kind: k, Detail: d}) }, + log: log, + every: reloadEvery, + } + go rel.Run(ctx) + } logComfyArmed(log, comfy) if cfg.Metrics.Listen != "" { go func() { diff --git a/internal/config/config.go b/internal/config/config.go index 7f7da97..9023e3f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -276,8 +276,12 @@ type ClaimsConfig struct { // ClaimRule is one [[claim]]: work Berth does not run that holds the card // (action "hold", the default) or has its card memory named ("track"). type ClaimRule struct { - Name string `toml:"name" json:"name" yaml:"name"` + Name string `toml:"name" json:"name" yaml:"name"` + // Exe are globs on the process name; Paths are plain executable paths, + // one per line, no regex: a process whose image path equals one of them + // matches. Exe []string `toml:"exe,omitempty" json:"exe,omitempty" yaml:"exe,omitempty"` + Paths []string `toml:"paths,omitempty" json:"paths,omitempty" yaml:"paths,omitempty"` Cmdline string `toml:"cmdline,omitempty" json:"cmdline,omitempty" yaml:"cmdline,omitempty"` Not string `toml:"not_cmdline,omitempty" json:"not_cmdline,omitempty" yaml:"not_cmdline,omitempty"` Parent []string `toml:"parent,omitempty" json:"parent,omitempty" yaml:"parent,omitempty"` @@ -298,7 +302,7 @@ func (c *Config) ClaimRules() ([]claims.Rule, error) { var out []claims.Rule names := map[string]bool{} for _, r := range c.Claim { - rule := claims.Rule{Name: r.Name, Exe: r.Exe, Cmdline: r.Cmdline, Not: r.Not, Parent: r.Parent, ParentCmdline: r.ParentCmdline, + rule := claims.Rule{Name: r.Name, Exe: r.Exe, Paths: r.Paths, Cmdline: r.Cmdline, Not: r.Not, Parent: r.Parent, ParentCmdline: r.ParentCmdline, Action: r.Action, NeedsMiB: r.NeedsMiB, Linger: r.Linger.Duration, GPU: r.GPU} if err := rule.Compile(); err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c421d06..6e227f4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -63,6 +63,37 @@ output_tokens = "my_generated_total" } } +// A claim names its games with plain paths, one per line, no regex: they +// load and reach the compiled rule. +func TestClaimPathsLoadAndCompile(t *testing.T) { + const pbz = `D:\Games\!Offline Activation\Pub\PBZ\platform\steam\PBZ.exe` + c, err := Load(write(t, fmt.Sprintf(` +[[claim]] +name = "games" +paths = ['%s'] +linger = "30s" +`, pbz))) + if err != nil { + t.Fatal(err) + } + if len(c.Claim) != 1 || c.Claim[0].Paths[0] != pbz { + t.Fatalf("claim = %+v", c.Claim) + } + rules, err := c.ClaimRules() + if err != nil { + t.Fatal(err) + } + found := false + for _, r := range rules { + if r.Name == "games" && len(r.Paths) == 1 && r.Paths[0] == pbz { + found = true + } + } + if !found { + t.Fatalf("paths did not reach the compiled rule: %+v", rules) + } +} + func TestEveryProblemReportedAtOnce(t *testing.T) { _, err := Load(write(t, ` [metrics] diff --git a/internal/engines/engines.go b/internal/engines/engines.go index faff156..5317e91 100644 --- a/internal/engines/engines.go +++ b/internal/engines/engines.go @@ -64,6 +64,15 @@ type Watcher struct { held map[string]heldRates // engine -> the rates its counters last measured } +// SetEngines swaps in a reloaded engine list. The state of surviving engines +// (health, the request log) is keyed by name and moves with them; a removed +// engine's state ages out of the window on its own. +func (w *Watcher) SetEngines(e []config.Engine) { + w.mu.Lock() + defer w.mu.Unlock() + w.Engines = e +} + // all is the configured engines then the discovered ones. Called with w.mu held. func (w *Watcher) all() []config.Engine { out := make([]config.Engine, 0, len(w.Engines)+len(w.found)) diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 17ca6d3..867364f 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -262,6 +262,11 @@ const ( // host driver. Each fires once per transition. EvCardPassthrough = "card.passthrough" EvCardReturned = "card.returned" + // EvConfigReloaded: berth.toml changed and the daemon applied it; the + // detail says what moved. EvConfigRejected: the new file failed to + // validate; the applied config kept, the detail says why. + EvConfigReloaded = "config.reloaded" + EvConfigRejected = "config.rejected" ) // Card states, the values Status.Passthrough.State takes. Normal is the