From 4f6cbd2197e89e00da6e0970f235a8498b737e68 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 15:54:15 +0200 Subject: [PATCH] Follow bar values so a dead watch speaks, date them by their reading, and widen the path check (hq ADR 0260) A watch that died left the block silent, a removed block kept its value, and a value frozen at its source looked fresh because its file was new. The follower now rereads a silent value and says so, drops removed blocks, and dates each file by the reading's own time. The path check takes /etc and /usr/local/lib in, ignores what a container sees inside itself, and lists the five findings standing today, which may only go. --- checks/paths/known | 8 + checks/paths/main.go | 145 +++++++++++-- checks/paths/main_test.go | 46 ++++ checks/paths/shared | 3 + merge-check.sh | 5 +- modules/i3status-rust/README.md | 9 +- .../cmd/i3status-rust-tools/values.go | 205 ++++++++++++++---- .../cmd/i3status-rust-tools/values_test.go | 139 +++++++++--- modules/power/README.md | 3 +- modules/power/module.json | 3 +- 10 files changed, 465 insertions(+), 101 deletions(-) create mode 100644 checks/paths/known create mode 100644 checks/paths/shared diff --git a/checks/paths/known b/checks/paths/known new file mode 100644 index 00000000..a6c036da --- /dev/null +++ b/checks/paths/known @@ -0,0 +1,8 @@ +# Findings standing when the check took /etc and /usr/local/lib in (2026-10-08). Each is a module reading +# another's file instead of asking over the bus. The list only shrinks: a finding that no longer occurs +# must leave it. Format: # why it is still here +asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks +asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same +asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same +asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/cmd/zephyrus/keys.go # its keys tool reads triggerhappy's triggers; should ask triggerhappy's tool +asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/module.json # comments naming triggerhappy's file, in files it writes diff --git a/checks/paths/main.go b/checks/paths/main.go index e0951a16..579fd0c1 100644 --- a/checks/paths/main.go +++ b/checks/paths/main.go @@ -1,11 +1,23 @@ // Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module -// names a path under /run or /var/lib that another module's manifest places. Such a path is that -// module's state on its machine, and a second module reading or writing it is two modules talking -// through a file, which the bus exists to replace. +// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc. +// Such a path is that module's state or configuration on its machine, and a second module reading or +// writing it is two modules talking through a file, which the bus exists to replace. // -// A module owns every `path` of its resources under /run/ or /var/lib/. Every other module's manifest -// and every file of its own except its documentation and its tests is searched for each, as a whole -// path: followed by nothing, a slash, or a character no path takes. +// A module owns every `path` of its resources under those roots. Every other module's manifest, and +// every file of its own but its documentation, its tests and its binaries, is searched for each as a +// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees +// inside itself (the target of a `volumes` entry) is the container's, and is not searched. +// +// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`: +// +// - shared: paths every program on a machine reads as the machine's, not as a message from their +// owner — /etc/hosts, which every resolver reads. +// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a +// finding not listed, and on a listed one that no longer occurs, so the list only shrinks. +// +// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read +// from the environment or a setting, and one reached through a shell the module runs. It reads text, +// not what a program does; review and the runtime's grants cover the rest. // // go run . // @@ -13,6 +25,8 @@ package main import ( + "bufio" + "bytes" "encoding/json" "fmt" "io/fs" @@ -23,13 +37,8 @@ import ( "strings" ) -// stateRoots are where a machine keeps a module's state. -var stateRoots = []string{"/run/", "/var/lib/"} - -type manifest struct { - Module string `json:"module"` - Resources []map[string]any `json:"resources"` -} +// stateRoots are where a machine keeps a module's state and configuration. +var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"} // Finding is one module naming another's path. type Finding struct { @@ -41,20 +50,50 @@ func (f Finding) String() string { f.Module, f.Path, f.File, f.Owner) } -// Check is every finding in the catalogue at root. +// Key is how a finding is listed in `known`: the module, the path and the file. +func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File } + +// containerTargets drops what a container sees inside itself from a manifest before it is searched: +// the target half of every `volumes` entry of a container resource. +func containerTargets(m map[string]any) { + resources, _ := m["resources"].([]any) + for _, raw := range resources { + r, ok := raw.(map[string]any) + if !ok || r["type"] != "container" { + continue + } + volumes, _ := r["volumes"].([]any) + for i, v := range volumes { + if s, ok := v.(string); ok { + if src, _, two := strings.Cut(s, ":"); two { + volumes[i] = src + } + } + if o, ok := v.(map[string]any); ok { + delete(o, "target") + delete(o, "container") + } + } + } +} + +// Check is every finding in the catalogue at root, before the lists narrow it. func Check(root string) ([]Finding, error) { - dirs, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) if err != nil { return nil, err } owner := map[string]string{} modules := map[string]string{} - for _, f := range dirs { + for _, f := range files { raw, err := os.ReadFile(f) if err != nil { return nil, err } - var m manifest + var m struct { + Module string `json:"module"` + Resources []map[string]any `json:"resources"` + } if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("%s: %w", f, err) } @@ -77,15 +116,14 @@ func Check(root string) ([]Finding, error) { for _, p := range paths { patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`) } - var out []Finding names := make([]string, 0, len(modules)) for n := range modules { names = append(names, n) } sort.Strings(names) + var out []Finding for _, module := range names { - dir := modules[module] - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(modules[module], func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } @@ -98,6 +136,16 @@ func Check(root string) ([]Finding, error) { if err != nil { return err } + if bytes.IndexByte(raw, 0) >= 0 { + return nil // a binary: what it says is in its source + } + if name == "module.json" { + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + containerTargets(m) + raw, _ = json.Marshal(m) + } + } text := string(raw) for _, p := range paths { if owner[p] != module && patterns[p].MatchString(text) { @@ -114,6 +162,46 @@ func Check(root string) ([]Finding, error) { return out, nil } +// list reads one of the lists beside this check: each line's text before a `#`, trimmed. +func list(path string) (map[string]bool, error) { + out := map[string]bool{} + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line, _, _ := strings.Cut(s.Text(), "#") + if line = strings.TrimSpace(line); line != "" { + out[line] = true + } + } + return out, s.Err() +} + +// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no +// longer occur, which must leave the list. +func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) { + seen := map[string]bool{} + for _, f := range found { + if shared[f.Path] { + continue + } + seen[f.Key()] = true + if !known[f.Key()] { + refused = append(refused, f) + } + } + for k := range known { + if !seen[k] { + gone = append(gone, k) + } + } + sort.Strings(gone) + return refused, gone +} + func main() { root := "." if len(os.Args) > 1 { @@ -124,11 +212,22 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(2) } - for _, f := range found { + here := filepath.Join(root, "checks", "paths") + shared, err1 := list(filepath.Join(here, "shared")) + known, err2 := list(filepath.Join(here, "known")) + if err1 != nil || err2 != nil { + fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2) + os.Exit(2) + } + refused, gone := Judge(found, shared, known) + for _, f := range refused { fmt.Println(f) } - if len(found) > 0 { + for _, k := range gone { + fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k) + } + if len(refused) > 0 || len(gone) > 0 { os.Exit(1) } - fmt.Println("paths: no module names another's state on its machine") + fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known)) } diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go index 6c24540e..c70c5314 100644 --- a/checks/paths/main_test.go +++ b/checks/paths/main_test.go @@ -61,3 +61,49 @@ func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) { t.Fatalf("%v %v", found, err) } } + +func TestEtcAndUsrLocalLibAreAModulesTooButNotInsideAContainer(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": `{"module":"power","resources":[{"id":"m","type":"directory","path":"/etc/mesh-power/moments"},` + + `{"id":"s","type":"archive","path":"/usr/local/lib/mesh-power"}]}`, + "modules/reader/module.json": `{"module":"reader"}`, + "modules/reader/cmd/r/r.go": `const a, b = "/etc/mesh-power/moments/after-wake", "/usr/local/lib/mesh-power/bin/x"`, + // A container that mounts something at that path inside itself names its own world. + "modules/boxed/module.json": `{"module":"boxed","resources":[{"id":"c","type":"container",` + + `"volumes":["/srv/boxed:/etc/mesh-power/moments",{"source":"/srv/b","target":"/usr/local/lib/mesh-power"}]}]}`, + // A binary says what its source says. + "modules/boxed/bin/tool": "\x00\x01/etc/mesh-power/moments\x00", + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range found { + got = append(got, f.Module+" "+f.Path) + } + want := []string{"reader /etc/mesh-power/moments", "reader /usr/local/lib/mesh-power"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("found %v, want %v", got, want) + } +} + +func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) { + found := []Finding{ + {Module: "dns", File: "modules/dns/module.json", Path: "/etc/hosts", Owner: "hostname"}, + {Module: "laptop", File: "modules/laptop/k.go", Path: "/etc/triggers", Owner: "keys"}, + {Module: "new", File: "modules/new/n.go", Path: "/run/mesh-power", Owner: "power"}, + } + shared := map[string]bool{"/etc/hosts": true} + known := map[string]bool{ + "laptop /etc/triggers modules/laptop/k.go": true, + "old /run/x modules/old/o.go": true, + } + refused, gone := Judge(found, shared, known) + if len(refused) != 1 || refused[0].Module != "new" { + t.Fatalf("refused %v", refused) + } + if len(gone) != 1 || gone[0] != "old /run/x modules/old/o.go" { + t.Fatalf("gone %v", gone) + } +} diff --git a/checks/paths/shared b/checks/paths/shared new file mode 100644 index 00000000..e305bcff --- /dev/null +++ b/checks/paths/shared @@ -0,0 +1,3 @@ +# Paths every program on a machine reads as the machine's own, not as a message from the module that +# writes them (checks/paths, novox/hq ADR 0260). One per line, with why. +/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them diff --git a/merge-check.sh b/merge-check.sh index 8a6eff54..edec11d8 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -19,8 +19,9 @@ # retired-words, in any text a module's tools can show an agent — every string in its own code that is # not a test, and every description in its manifest. Run over every module, not only the touched ones, # so a word newly retired is found everywhere it stands. The check's own test runs first. -# 4. the paths (novox/hq ADR 0260): no module names a path under /run or /var/lib that another module's -# manifest places — modules talk over the bus, not through each other's files. Over every module. +# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc +# that another module's manifest places — modules talk over the bus, not through each other's files. +# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks. set -eu (cd checks/words && go test -count=1 . && go run . ../..) diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 49ec55d3..19721602 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -44,9 +44,12 @@ contributes it to `node-bar` as data that says what it shows, and this module re - **A value from the bus:** a block that `shows: state` names another module's state, such as `power.draw`. The mesh grants this module the read, because a module on its machine offers it; the manifest names no other module. This module's bundle watches it and leaves the value's text in the - account's runtime directory (`i3status-rust/values/`), and the block runs `~/.local/bin/i3status-value`, - which prints it, or nothing when it is more than 30 s old, so the block hides. No file of another - module is read: modules talk over the bus. + account's runtime directory (`i3status-rust/values/`), dated with the value's own `at`, and the block + runs `~/.local/bin/i3status-value`, which prints it, or nothing when it was measured more than 30 s + ago, so the block hides a value frozen at its source. A watch that says nothing for a minute is read + again and said in the log, a refused watch is said and tried again, and a value no bar shows any more + is no longer kept. The bus grants this module the one key each block shows (the machine's own unless + the block names another, `[a-z0-9-]`). No file of another module is read: modules talk over the bus. - **The battery** is i3status-rust's own battery block, which reads the kernel's power-supply class itself: the bar reading the machine, not another module. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go index 8ea8e2a7..b0ddc30c 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strconv" + "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -17,12 +18,22 @@ import ( // // A block that `shows` a state is rendered as a custom block running `i3status-value `. // i3status-rs cannot reach the bus, and this bundle can: the node's runtime launched it, and the mesh -// granted this module a read of every state the modules on its machine offer the bar. So it watches each -// state its bars name and leaves the value's text in this module's own directory, where the block's -// command reads it. No file of another module is read, and nothing but the bus passes between them. +// granted this module a read of the one key each such block shows. So it follows each state its bars +// name and leaves the value's text in this module's own directory, where the block's command reads it. +// No file of another module is read, and nothing but the bus passes between them. +// +// **The value's age is its own.** The file is dated with the value's `at` when it has one, so the +// block hides a value that stopped changing at its source, not only one that stopped arriving here. -// valueCall is how a bar names a value: the command the template renders. -var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([^\s"]+)`) +// FollowEvery is how often the bars are read again and every followed value looked at; Silent is how +// long a watch may say nothing before the value is read again — twice the power draw's lifetime. +var ( + FollowEvery = 30 * time.Second + Silent = 60 * time.Second +) + +// valueCall is how a bar names a value: the command the template renders. A key is a machine's name. +var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([a-z0-9][a-z0-9-]*)`) // Wanted is one state and key a bar shows. type Wanted struct{ State, Key string } @@ -54,10 +65,11 @@ func valuesDir() string { return filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "i3status-rust", "values") } -// keep writes one change of a value to its file: the value's `text`, or no file once it is deleted. -func keep(dir string, w Wanted, c stdio.StateChange) error { +// keep writes one value to its file: its `text`, dated with its `at` when it has one; no file once the +// value is deleted. +func keep(dir string, w Wanted, op string, value json.RawMessage) error { path := filepath.Join(dir, w.file()) - if c.Op == "delete" || c.Op == "purge" { + if op == "delete" || op == "purge" || value == nil { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } @@ -65,8 +77,9 @@ func keep(dir string, w Wanted, c stdio.StateChange) error { } var v struct { Text string `json:"text"` + At string `json:"at"` } - if err := json.Unmarshal(c.Value, &v); err != nil { + if err := json.Unmarshal(value, &v); err != nil { return fmt.Errorf("%s's value for %s has no text: %w", w.State, w.Key, err) } if err := os.MkdirAll(dir, 0o700); err != nil { @@ -76,43 +89,157 @@ func keep(dir string, w Wanted, c stdio.StateChange) error { if err := os.WriteFile(tmp, []byte(v.Text+"\n"), 0o600); err != nil { return err } + if at, err := time.Parse(time.RFC3339, v.At); err == nil { + if err := os.Chtimes(tmp, at, at); err != nil { + return err + } + } return os.Rename(tmp, path) } -// Watcher is a function that watches one key of one state; the SDK's in the bundle, a fake in a test. -type Watcher func(state, key string, handle func(stdio.StateChange) error) error - -// followValues watches every value the bars name, starting the ones not yet watched each time it is -// called; one the runtime refuses — a read not granted yet — is tried again the next time. -func followValues(files []string, dir string, watching map[Wanted]bool, watch Watcher) []string { - var said []string - for _, w := range wantedIn(files) { - if watching[w] { - continue - } - w := w - err := watch(w.State, w.Key, func(c stdio.StateChange) error { return keep(dir, w, c) }) - if err != nil { - said = append(said, fmt.Sprintf("cannot watch %s %s: %v", w.State, w.Key, err)) - continue - } - watching[w] = true - } - return said +// Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the +// bundle, a fake in a test. +type Bus struct { + Watch func(state, key string, handle func(op string, value json.RawMessage) error) error + Get func(state, key string) (json.RawMessage, bool, error) } -// FollowValues runs followValues every half minute for as long as the bundle runs, so a block a push -// adds is followed without a restart. -func FollowValues() { - watching := map[Wanted]bool{} - watch := func(state, key string, handle func(stdio.StateChange) error) error { - return stdio.State(state).Watch(key, handle) +// following is one value followed: when its watch last said anything, and whether a bar still shows it. +type following struct { + last time.Time + stopped bool +} + +// Follower follows the values the bars name. +type Follower struct { + Dir string + Bus Bus + Now func() time.Time + Say func(string) + + mu sync.Mutex + of map[Wanted]*following +} + +// Round reads the bars once and brings what is followed into line with them: +// - a value a bar names and nothing follows is watched; a watch the runtime refuses — a read not +// granted yet — is said and tried again next round; +// - a value whose watch has said nothing for Silent is read again, and said once, so a dead watch is +// never silent; +// - a value no bar names any more is no longer kept: its file goes, and what its watch hands over is +// dropped. The SDK has no way to end a watch; one that ends with the bundle is the price. +func (f *Follower) Round(files []string) { + f.mu.Lock() + if f.of == nil { + f.of = map[Wanted]*following{} } + f.mu.Unlock() + wanted := map[Wanted]bool{} + for _, w := range wantedIn(files) { + wanted[w] = true + f.mu.Lock() + fw, known := f.of[w] + f.mu.Unlock() + switch { + case !known: + f.start(w) + case fw.stopped: + f.mu.Lock() + fw.stopped = false + fw.last = f.Now() + f.mu.Unlock() + f.reread(w) + case f.Now().Sub(f.lastOf(w)) > Silent: + f.Say(fmt.Sprintf("the watch of %s %s has said nothing for %s; reading it again", w.State, w.Key, Silent)) + f.reread(w) + } + } + f.mu.Lock() + var gone []Wanted + for w, fw := range f.of { + if !wanted[w] && !fw.stopped { + fw.stopped = true + gone = append(gone, w) + } + } + f.mu.Unlock() + for _, w := range gone { + if err := keep(f.Dir, w, "delete", nil); err != nil { + f.Say(err.Error()) + } + f.Say(fmt.Sprintf("no bar shows %s %s any more; it is no longer kept", w.State, w.Key)) + } +} + +func (f *Follower) lastOf(w Wanted) time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.of[w].last +} + +func (f *Follower) start(w Wanted) { + err := f.Bus.Watch(w.State, w.Key, func(op string, value json.RawMessage) error { + f.mu.Lock() + fw := f.of[w] + if fw != nil { + fw.last = f.Now() + } + stopped := fw == nil || fw.stopped + f.mu.Unlock() + if stopped { + return nil + } + return keep(f.Dir, w, op, value) + }) + if err != nil { + f.Say(fmt.Sprintf("cannot watch %s %s: %v; trying again in %s", w.State, w.Key, err, FollowEvery)) + return + } + f.mu.Lock() + if fw := f.of[w]; fw == nil { + f.of[w] = &following{last: f.Now()} + } + f.mu.Unlock() +} + +func (f *Follower) reread(w Wanted) { + value, found, err := f.Bus.Get(w.State, w.Key) + f.mu.Lock() + f.of[w].last = f.Now() + f.mu.Unlock() + if err != nil { + f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err)) + return + } + op := "put" + if !found { + op = "delete" + } + if err := keep(f.Dir, w, op, value); err != nil { + f.Say(err.Error()) + } +} + +// FollowValues runs a Round every FollowEvery for as long as the bundle runs, so a block a push adds +// is followed without a restart. +func FollowValues() { + f := &Follower{Dir: valuesDir(), Now: time.Now, + Say: func(s string) { fmt.Fprintln(os.Stderr, "i3status-rust:", s) }, + Bus: Bus{ + Watch: func(state, key string, handle func(string, json.RawMessage) error) error { + return stdio.State(state).Watch(key, func(c stdio.StateChange) error { return handle(c.Op, c.Value) }) + }, + Get: func(state, key string) (json.RawMessage, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }, + }} for { files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml")) - for _, s := range followValues(files, valuesDir(), watching, watch) { - fmt.Fprintln(os.Stderr, "i3status-rust:", s) - } - time.Sleep(30 * time.Second) + f.Round(files) + time.Sleep(FollowEvery) } } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go index e7ab28d2..b7e7a633 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "os" "os/exec" @@ -8,56 +9,128 @@ import ( "strings" "testing" "time" - - stdio "git.novox.be/novox/mesh-sdk/go" ) // Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this -// module's own bundle and directory, and an old one is not shown as if it were now. +// module's own bundle and directory; a dead watch is never silent; a removed block is no longer kept; +// and a value frozen at its source hides. -func TestAValueIsKeptAsItsTextAndGoneWhenDeleted(t *testing.T) { +func TestAValueIsKeptAsItsTextDatedByItsAtAndGoneWhenDeleted(t *testing.T) { dir := t.TempDir() w := Wanted{"power.draw", "laptop"} - if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`{"text":"18.2 W machine","watts":18.2}`)}); err != nil { + at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + if err := keep(dir, w, "put", json.RawMessage(`{"text":"18.2 W machine","at":"2026-10-08T12:00:00Z"}`)); err != nil { t.Fatal(err) } - b, err := os.ReadFile(filepath.Join(dir, "power.draw@laptop")) - if err != nil || string(b) != "18.2 W machine\n" { - t.Fatalf("%q %v", b, err) + path := filepath.Join(dir, "power.draw@laptop") + b, err := os.ReadFile(path) + info, _ := os.Stat(path) + if err != nil || string(b) != "18.2 W machine\n" || !info.ModTime().Equal(at) { + t.Fatalf("%q %v, dated %v", b, err, info.ModTime()) } - if err := keep(dir, w, stdio.StateChange{Op: "delete"}); err != nil { + if err := keep(dir, w, "delete", nil); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("a deleted value is still shown") } - if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`"no text"`)}); err == nil { + if err := keep(dir, w, "put", json.RawMessage(`"no text"`)); err == nil { t.Fatal("a value with no text was kept") } } -func TestARefusedWatchIsTriedAgainAndAWatchedOneIsNot(t *testing.T) { - bar := filepath.Join(t.TempDir(), "bottom-bar.toml") - if err := os.WriteFile(bar, []byte(`command = "~/.local/bin/i3status-value power.draw laptop"`+"\n"), 0o644); err != nil { +type fakeBus struct { + refuse bool + watched []Wanted + handles map[Wanted]func(string, json.RawMessage) error + gets int + value json.RawMessage +} + +func (b *fakeBus) bus() Bus { + return Bus{ + Watch: func(state, key string, h func(string, json.RawMessage) error) error { + if b.refuse { + return errors.New("not granted") + } + w := Wanted{state, key} + b.watched = append(b.watched, w) + if b.handles == nil { + b.handles = map[Wanted]func(string, json.RawMessage) error{} + } + b.handles[w] = h + return nil + }, + Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.value != nil, nil }, + } +} + +func barFiles(t *testing.T, content string) []string { + t.Helper() + f := filepath.Join(t.TempDir(), "bottom-bar.toml") + if err := os.WriteFile(f, []byte(content), 0o644); err != nil { t.Fatal(err) } - watching := map[Wanted]bool{} - calls := 0 - refuse := func(string, string, func(stdio.StateChange) error) error { calls++; return errors.New("not granted") } - if said := followValues([]string{bar}, t.TempDir(), watching, refuse); len(said) != 1 || !strings.Contains(said[0], "not granted") { + return []string{f} +} + +func TestAFollowerSaysARefusedWatchRereadsASilentOneAndDropsARemovedBlock(t *testing.T) { + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + var said []string + b := &fakeBus{refuse: true} + dir := t.TempDir() + f := &Follower{Dir: dir, Bus: b.bus(), Now: func() time.Time { return now }, Say: func(s string) { said = append(said, s) }} + bar := barFiles(t, `command = "~/.local/bin/i3status-value power.draw laptop"`) + + // Refused: said, and tried again the next round. + f.Round(bar) + if len(said) != 1 || !strings.Contains(said[0], "cannot watch power.draw laptop: not granted") { t.Fatalf("%v", said) } - accept := func(state, key string, _ func(stdio.StateChange) error) error { - calls++ - if state != "power.draw" || key != "laptop" { - t.Fatalf("watched %s %s", state, key) - } - return nil + b.refuse = false + f.Round(bar) + if len(b.watched) != 1 { + t.Fatalf("watched %v", b.watched) } - followValues([]string{bar}, t.TempDir(), watching, accept) - followValues([]string{bar}, t.TempDir(), watching, accept) - if calls != 2 { - t.Fatalf("%d watches; once refused, once taken, then left alone", calls) + + // Silent for longer than Silent: said, and read again. + b.value = json.RawMessage(`{"text":"9.0 W CPU","at":"2026-10-08T12:01:00Z"}`) + now = now.Add(Silent + time.Second) + f.Round(bar) + if b.gets != 1 || !strings.Contains(said[len(said)-1], "has said nothing") { + t.Fatalf("gets %d, said %v", b.gets, said) + } + if got, _ := os.ReadFile(filepath.Join(dir, "power.draw@laptop")); string(got) != "9.0 W CPU\n" { + t.Fatalf("%q", got) + } + // Not again while the watch speaks. + if err := b.handles[Wanted{"power.draw", "laptop"}]("put", b.value); err != nil { + t.Fatal(err) + } + f.Round(bar) + if b.gets != 1 { + t.Fatalf("read again while the watch speaks: %d", b.gets) + } + + // Its block removed: the file goes, and what the watch still hands over is dropped. + f.Round(barFiles(t, "no values here")) + if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + t.Fatal("a value no bar shows is still kept") + } + if err := b.handles[Wanted{"power.draw", "laptop"}]("put", b.value); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + t.Fatal("a stopped watch still writes") + } + if !strings.Contains(said[len(said)-1], "no longer kept") { + t.Fatalf("%v", said) + } +} + +func TestAKeyIsAMachinesName(t *testing.T) { + if w := wantedIn(barFiles(t, `i3status-value power.draw ../etc`+"\n"+`i3status-value power.draw Laptop`)); len(w) != 0 { + t.Fatalf("%v", w) } } @@ -76,17 +149,19 @@ func TestTheBlocksCommandShowsOnlyAFreshValue(t *testing.T) { if got := run(); got != "" { t.Fatalf("no value: %q", got) } - if err := keep(dir, Wanted{"power.draw", "laptop"}, stdio.StateChange{Op: "put", Value: []byte(`{"text":"8.0 W CPU"}`)}); err != nil { + fresh := time.Now().UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+fresh+`"}`)); err != nil { t.Fatal(err) } if got := run(); got != "8.0 W CPU" { t.Fatalf("fresh: %q", got) } - old := time.Now().Add(-31 * time.Second) - if err := os.Chtimes(filepath.Join(dir, "power.draw@laptop"), old, old); err != nil { + // A value measured 31 s ago hides, however recently it arrived. + old := time.Now().Add(-31 * time.Second).UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+old+`"}`)); err != nil { t.Fatal(err) } if got := run(); got != "" { - t.Fatalf("31 s old: %q", got) + t.Fatalf("measured 31 s ago: %q", got) } } diff --git a/modules/power/README.md b/modules/power/README.md index 22a01b2e..d1076525 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -58,7 +58,8 @@ permission is loosened. Two parts of this module carry the reading: 1. the process `mesh-power-draw` (`power draw`, as root) samples every 5 s and leaves the averaged rate in `/run/mesh-power/draw`, this module's own directory, read by nothing but this module; 2. the module's bundle, which the runtime launches, puts each fresh reading in its state `draw`, keyed by - the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`). + the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`). The + state is `per-machine`: each machine's instance is granted its own machine's key and no other. **State, not events, by bus load.** A reading is the current value and is replaced every five seconds; nobody needs the ones before. A key per machine keeps one value, so the bus holds at most one reading diff --git a/modules/power/module.json b/modules/power/module.json index faa32384..6411982c 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -23,7 +23,8 @@ "state": [ { "name": "draw", - "ttl-seconds": 30 + "ttl-seconds": 30, + "per-machine": true } ], "tools": [