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.
This commit is contained in:
@@ -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: <module> <path> <file> # 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
|
||||
+122
-23
@@ -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 . <catalogue root>
|
||||
//
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+3
-2
@@ -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 . ../..)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 <state> <key>`.
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"state": [
|
||||
{
|
||||
"name": "draw",
|
||||
"ttl-seconds": 30
|
||||
"ttl-seconds": 30,
|
||||
"per-machine": true
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
|
||||
Reference in New Issue
Block a user