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:
jochen
2026-10-08 16:12:37 +02:00
parent 76b63744e6
commit 4f6cbd2197
10 changed files with 465 additions and 101 deletions
+6 -3
View File
@@ -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)
}
}
+2 -1
View File
@@ -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
+2 -1
View File
@@ -23,7 +23,8 @@
"state": [
{
"name": "draw",
"ttl-seconds": 30
"ttl-seconds": 30,
"per-machine": true
}
],
"tools": [