From 0079ff253e7b8ed74c4ce5a0ef2eeb44c9f9364d Mon Sep 17 00:00:00 2001 From: r3dbaba Date: Tue, 3 Nov 2026 01:50:25 -0500 Subject: [PATCH 1/2] feat(claims): match claim rules on plain executable paths A [[claim]] can now name its games with paths, a list of plain executable paths, one per line, no regex: a process matches when its image path equals one of them, case-insensitively. That is the shape a per-game opt-in list wants to take - adding a game is adding a line, not writing and escaping a regex fragment - and it ANDs with the other matchers like every other matcher. --- docs/leasing.md | 6 ++++-- internal/claims/claims.go | 38 ++++++++++++++++++++++++++++----- internal/claims/claims_test.go | 37 ++++++++++++++++++++++++++++++++ internal/claims/scan_linux.go | 6 ++++++ internal/claims/scan_windows.go | 1 + internal/config/config.go | 8 +++++-- internal/config/config_test.go | 31 +++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 9 deletions(-) diff --git a/docs/leasing.md b/docs/leasing.md index 11fa9d4..28a0968 100644 --- a/docs/leasing.md +++ b/docs/leasing.md @@ -113,8 +113,10 @@ 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. - **`berth hold [--for 2h] [--reason game]`** takes the card for you. It is a pinned lease at 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..2eaf5ab 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{ 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/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] From 6509a1d4684d3cc91e4c119471d7aa7c4fd4d96b Mon Sep 17 00:00:00 2001 From: r3dbaba Date: Tue, 3 Nov 2026 01:53:16 -0500 Subject: [PATCH 2/2] feat(daemon): apply a changed berth.toml without a restart berthd stats the config file once a second. A changed file is re-validated through the same path berth config check uses and, when it holds, its claim rules, the brokers' scheduling fields (priority order, juggle window, squat allow list, ComfyUI list, the park budget) and the engine list apply at once; the claim watcher gains a SetRules swap that ends the holds of removed rules so a hold never outlives its rule. 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 stay put and need a restart: the scan and poll intervals, the metrics listener, the suite's model roots, and the gateway's own copy. --- docs/leasing.md | 8 ++ internal/broker/liveconfig.go | 39 ++++++++++ internal/broker/liveconfig_test.go | 31 ++++++++ internal/claims/claims_test.go | 43 +++++++++++ internal/claims/watch.go | 28 +++++++ internal/cli/config_reload.go | 79 ++++++++++++++++++++ internal/cli/config_reload_test.go | 116 +++++++++++++++++++++++++++++ internal/cli/daemon.go | 58 ++++++++++++++- internal/engines/engines.go | 9 +++ internal/proto/proto.go | 5 ++ 10 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 internal/broker/liveconfig.go create mode 100644 internal/broker/liveconfig_test.go create mode 100644 internal/cli/config_reload.go create mode 100644 internal/cli/config_reload_test.go diff --git a/docs/leasing.md b/docs/leasing.md index 28a0968..ceaddb6 100644 --- a/docs/leasing.md +++ b/docs/leasing.md @@ -119,6 +119,14 @@ 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_test.go b/internal/claims/claims_test.go index 2eaf5ab..e5aeb1f 100644 --- a/internal/claims/claims_test.go +++ b/internal/claims/claims_test.go @@ -314,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/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/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