Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)

Modules talk over the bus. The power module's bundle puts each fresh reading of its own
sampler in its state draw, keyed by the machine; the bar's block shows that state, which
the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses
a module naming another module's path under /run or /var/lib.
This commit is contained in:
jochen
2026-10-08 16:12:37 +02:00
parent e1b7fcd8c9
commit 76b63744e6
21 changed files with 697 additions and 73 deletions
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/paths
go 1.22
+134
View File
@@ -0,0 +1,134 @@
// 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.
//
// 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.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"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"`
}
// Finding is one module naming another's path.
type Finding struct {
Module, File, Path, Owner string
}
func (f Finding) String() string {
return fmt.Sprintf("%s names %s in %s, and the path is %s's; modules talk over the bus (novox/hq ADR 0260)",
f.Module, f.Path, f.File, f.Owner)
}
// Check is every finding in the catalogue at root.
func Check(root string) ([]Finding, error) {
dirs, 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 {
raw, err := os.ReadFile(f)
if err != nil {
return nil, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", f, err)
}
modules[m.Module] = filepath.Dir(f)
for _, r := range m.Resources {
p, _ := r["path"].(string)
for _, root := range stateRoots {
if strings.HasPrefix(p, root) && len(p) > len(root) {
owner[strings.TrimRight(p, "/")] = m.Module
}
}
}
}
paths := make([]string, 0, len(owner))
for p := range owner {
paths = append(paths, p)
}
sort.Strings(paths)
patterns := map[string]*regexp.Regexp{}
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)
for _, module := range names {
dir := modules[module]
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
name := d.Name()
if strings.HasSuffix(name, ".md") || strings.HasSuffix(name, "_test.go") ||
name == "go.sum" || name == "package-lock.json" {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
text := string(raw)
for _, p := range paths {
if owner[p] != module && patterns[p].MatchString(text) {
rel, _ := filepath.Rel(root, path)
out = append(out, Finding{Module: module, File: rel, Path: p, Owner: owner[p]})
}
}
return nil
})
if err != nil {
return nil, err
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("paths: no module names another's state on its machine")
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func catalogue(t *testing.T, files map[string]string) string {
t.Helper()
root := t.TempDir()
for p, c := range files {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(c), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
const owner = `{"module":"power","resources":[{"id":"d","type":"directory","path":"/run/mesh-power"}]}`
func TestAModuleReadingAnothersStateIsFound(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/power/module.json": owner,
"modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`,
"modules/bar/module.json": `{"module":"bar","resources":[{"id":"x","type":"file","path":"/etc/x","content":"cat /run/mesh-power/draw"}]}`,
"modules/other/module.json": `{"module":"other"}`,
"modules/other/files/bin/read": "#!/bin/sh\ncat /run/mesh-power\n",
"modules/other/README.md": "it once read /run/mesh-power/draw",
"modules/other/cmd/x/x_test.go": `"/run/mesh-power"`,
"modules/near/module.json": `{"module":"near","resources":[{"id":"y","type":"file","path":"/etc/y","content":"/run/mesh-power-other/x"}]}`,
})
found, err := Check(root)
if err != nil {
t.Fatal(err)
}
var got []string
for _, f := range found {
got = append(got, f.Module+" "+f.File)
}
want := []string{"bar modules/bar/module.json", "other modules/other/files/bin/read"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("found %v, want %v", got, want)
}
if !strings.Contains(found[0].String(), "power's") {
t.Fatalf("%s", found[0])
}
}
func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) {
root := catalogue(t, map[string]string{
"modules/power/module.json": owner,
"modules/power/cmd/power/draw.go": `const f = "/run/mesh-power/draw"`,
})
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("%v %v", found, err)
}
}
+3
View File
@@ -19,9 +19,12 @@
# 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.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
(cd checks/paths && go test -count=1 . && go run . ../..)
if [ -n "${MESH_GATE:-}" ]; then
checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; }
+8
View File
@@ -41,6 +41,14 @@ contributes it to `node-bar` as data that says what it shows, and this module re
- **Where it applies:** a contribution may name a capability the machine must report. The `power`
module's battery block names `battery` and its power-draw block names `power-meter`, so the laptop's
bar gets both and a desktop only the power draw.
- **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.
- **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.
Still left out:
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"text/template"
@@ -33,7 +34,7 @@ func blockTemplate(t *testing.T) *template.Template {
func render(t *testing.T, tmpl *template.Template, piece string) string {
t.Helper()
view := map[string]any{"module": "power"}
view := map[string]any{"module": "power", "machine": "laptop"}
if err := json.Unmarshal([]byte(piece), &view); err != nil {
t.Fatal(err)
}
@@ -96,3 +97,24 @@ func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) {
t.Fatal("the memory block counts the cache as used")
}
}
func TestAContributedStateIsACustomBlockReadingThisMachinesValue(t *testing.T) {
tmpl := blockTemplate(t)
got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`)
want := "[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-value power.draw laptop\"\ninterval = 5\nhide_when_empty = true\n\n"
if got != want {
t.Fatalf("%q, want %q", got, want)
}
got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw","key":"desk"}}`)
if !strings.Contains(got, "i3status-value power.draw desk") {
t.Fatalf("%q", got)
}
// And the bundle finds what it rendered.
f := filepath.Join(t.TempDir(), "bottom-bar.toml")
if err := os.WriteFile(f, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
if w := wantedIn([]string{f}); len(w) != 1 || w[0] != (Wanted{"power.draw", "desk"}) {
t.Fatalf("%v", w)
}
}
@@ -11,6 +11,8 @@ import (
)
func main() {
// The values other modules keep on the bus for their blocks (novox/hq ADR 0260).
go FollowValues()
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -33,6 +33,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) {
for id, src := range map[string]string{
"top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml",
"updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog",
"value": "files/bin/i3status-value",
} {
m.sameAsSource(t, id, src)
}
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The values other modules keep on the bus for a block they contributed (novox/hq ADR 0260).
//
// 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.
// 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"]+)`)
// Wanted is one state and key a bar shows.
type Wanted struct{ State, Key string }
func (w Wanted) file() string { return w.State + "@" + w.Key }
// wantedIn is every value the bars' files name, each once, in order.
func wantedIn(files []string) []Wanted {
seen := map[Wanted]bool{}
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
continue
}
for _, m := range valueCall.FindAllStringSubmatch(string(raw), -1) {
seen[Wanted{State: m[1], Key: m[2]}] = true
}
}
out := make([]Wanted, 0, len(seen))
for w := range seen {
out = append(out, w)
}
sort.Slice(out, func(i, j int) bool { return out[i].file() < out[j].file() })
return out
}
// valuesDir is where the values are left: the account's runtime directory, gone at logout.
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 {
path := filepath.Join(dir, w.file())
if c.Op == "delete" || c.Op == "purge" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
var v struct {
Text string `json:"text"`
}
if err := json.Unmarshal(c.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 {
return err
}
tmp := path + ".new"
if err := os.WriteFile(tmp, []byte(v.Text+"\n"), 0o600); 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
}
// 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)
}
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)
}
}
@@ -0,0 +1,92 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"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.
func TestAValueIsKeptAsItsTextAndGoneWhenDeleted(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 {
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)
}
if err := keep(dir, w, stdio.StateChange{Op: "delete"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !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 {
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 {
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") {
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
}
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)
}
}
func TestTheBlocksCommandShowsOnlyAFreshValue(t *testing.T) {
runtime := t.TempDir()
dir := filepath.Join(runtime, "i3status-rust", "values")
run := func() string {
cmd := exec.Command("sh", "../../files/bin/i3status-value", "power.draw", "laptop")
cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+runtime)
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(out))
}
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 {
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 {
t.Fatal(err)
}
if got := run(); got != "" {
t.Fatalf("31 s old: %q", got)
}
}
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# i3status-value <state> <key> (module i3status-rust, novox/hq ADR 0260): one value another module keeps
# on the bus, for a block it contributed.
#
# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves
# the value's text in this module's own directory; this prints it. A value not refreshed for thirty
# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an
# old number as if it were now.
dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values"
f="$dir/$1@$2"
if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then
head -n 1 "$f"
fi
+7 -1
View File
@@ -1,5 +1,5 @@
{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller
renders one piece at a time: its fields (bar, place, order, shows, options) and module. */ -}}
renders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}
{{- if eq .shows "battery" -}}
[[block]]
block = "battery"
@@ -16,5 +16,11 @@ missing_format = ""
block = "custom"
command = {{quote .options.command}}
interval = {{.options.interval}}
{{- else if eq .shows "state" -}}
[[block]]
block = "custom"
command = {{quote (printf "~/.local/bin/i3status-value %s %s" .options.state (or .options.key .machine))}}
interval = 5
hide_when_empty = true
{{- end}}
+9 -1
View File
@@ -12,7 +12,7 @@
"name": "node-bar",
"scope": "node",
"renders": {
"block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options) and module. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- end}}\n\n"
"block": "{{- /* A block other modules contribute, in i3status-rust's grammar (novox/hq ADR 0255). The controller\nrenders one piece at a time: its fields (bar, place, order, shows, options), module and machine. */ -}}\n{{- if eq .shows \"battery\" -}}\n[[block]]\nblock = \"battery\"\n{{with .options}}{{with .device}}device = {{quote .}}\n{{end}}{{end -}}\nformat = \"$icon $percentage{ $time|}\"\ncharging_format = \"$icon $percentage{ $time|}\"\nnot_charging_format = \"$icon $percentage\"\nfull_format = \"$icon $percentage\"\nempty_format = \"$icon $percentage\"\nmissing_format = \"\"\n{{- else if eq .shows \"command\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote .options.command}}\ninterval = {{.options.interval}}\n{{- else if eq .shows \"state\" -}}\n[[block]]\nblock = \"custom\"\ncommand = {{quote (printf \"~/.local/bin/i3status-value %s %s\" .options.state (or .options.key .machine))}}\ninterval = 5\nhide_when_empty = true\n{{- end}}\n\n"
}
}
],
@@ -88,6 +88,14 @@
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n"
},
{
"id": "value",
"type": "file",
"path": "${machine:account-home}/.local/bin/i3status-value",
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/bin/sh\n# i3status-value <state> <key> (module i3status-rust, novox/hq ADR 0260): one value another module keeps\n# on the bus, for a block it contributed.\n#\n# This module's own tools bundle watches the state on the bus (the mesh grants it the read) and leaves\n# the value's text in this module's own directory; this prints it. A value not refreshed for thirty\n# seconds is one its module stopped giving, and prints nothing, so the block hides rather than show an\n# old number as if it were now.\ndir=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/i3status-rust/values\"\nf=\"$dir/$1@$2\"\nif [ -r \"$f\" ] && [ $(( $(date +%s) - $(stat -c %Y \"$f\") )) -le 30 ]; then\n\thead -n 1 \"$f\"\nfi\n"
}
],
"build": {
+24 -14
View File
@@ -13,7 +13,7 @@ states on the bus.
| `/etc/mesh-power/moments/<moment>` | for each moment, every module's code as the controller placed it (`${shell:<moment>:<slot>}`) |
| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` |
| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` |
| `mesh-power-draw` (process) | samples the power drawn and publishes it to `/run/mesh-power/draw` for the bar (below) |
| `mesh-power-draw` (process) | samples the power drawn for the module's bundle, which puts it on the bus as `power.draw` (below) |
| `/etc/udev/rules.d/90-mesh-power.rules` | a power supply's change starts `mesh-power-supply`, which runs `on-mains` or `on-battery` once per change of source |
## The moments, and how a module adds code
@@ -32,7 +32,7 @@ Code that must run *inside* the sleep transaction, such as the NVIDIA driver's o
resume units, is not a contribution. Its module keeps its own drop-ins on the sleep services (ADR
0211, "What got harder").
## The bar's battery and power draw (novox/hq ADR 0255)
## The bar's battery and power draw (novox/hq ADR 0255, ADR 0260)
The module holds the machine's power everywhere, so it gives the bar what is generic about power, on
the machines that have it. Both are contributions to `node-bar` in the bar's own neutral terms, so any
@@ -40,8 +40,8 @@ bar renders them, and each names the capability the node-engine reports for it:
| block | where | what it shows |
|---|---|---|
| battery | `if-capability: battery` (a Battery supply not scoped to a device) | the charge, and the time left or to full |
| power draw | `if-capability: power-meter` (a system battery, a powercap package counter, or a graphics device that reports its power) | one line from `bin/power-draw` |
| battery | `if-capability: battery` | the bar's own battery block: the bar reads the kernel's power-supply class itself, not anything of this module's |
| power draw | `if-capability: power-meter` | this module's state `power.draw` for the machine, over the bus |
The power draw says what it measured:
@@ -51,16 +51,26 @@ The power draw says what it measured:
`14.0 W CPU`. That is part of the machine, and is called that part.
- **The machine at the wall** needs a metered plug or a UPS and is not measured.
**The energy counter stays root's.** `energy_uj` is readable by root alone on purpose: read often by
anyone, it leaks what the processor is doing (the PLATYPUS attack). So no permission is loosened. The
module's process `mesh-power-draw` (`power draw`, as root) samples every 5 s and writes only the
averaged rate, one line, to `/run/mesh-power/draw` (0644). The bar's script prints that line, and says
`power not measured` when it is missing or older than 30 s. Graphics are counted honestly: an AMD
device only when it has its own memory (an APU's integrated graphics report the whole package, which
the counter already holds), and only while it is awake; an NVIDIA device through `nvidia-smi` only
while it is awake, so the bar never wakes a sleeping GPU. Each package is counted once by its name:
where Intel shows one twice (`intel-rapl:N` and `intel-rapl-mmio:N`), the first is read. A counter that
gives more than 1000 W was reset, after a resume say, and that sample gives no reading.
**Over the bus, not through a file.** Modules talk over the bus. The energy counter is root's to read,
on purpose: read often by anyone, it leaks what the processor is doing (the PLATYPUS attack), so no
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`).
**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
per machine, and a bar starting later reads it at once. An event every five seconds would keep each one
in a stream. On four machines the puts are under one message a second.
The bar's holder is granted the read by the mesh, because this module offers the state in its block; it
names no module. Graphics are counted honestly: an AMD device only when it has its own memory (an APU's
integrated graphics report the whole package, which the counter already holds), and only while it is
awake; an NVIDIA device through `nvidia-smi` only while it is awake. Each package is counted once by its
name: where Intel shows one twice (`intel-rapl:N` and `intel-rapl-mmio:N`), the first is read. A counter
that gives more than 1000 W was reset, after a resume say, and that sample gives no reading.
**The sampler runs wherever this module is assigned, servers included.** A server has the energy
counter too, so conditioning it on `power-meter` would not keep it off one, and the mesh has no way yet
+39 -7
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -21,10 +22,12 @@ import (
//
// The energy counter is root's to read, on purpose: read often, it leaks what the processor is doing
// (the PLATYPUS attack). So the counter is never opened to the operator's account. `power draw` runs as
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate, one line,
// to DrawFile — readable by anyone, and too coarse to leak anything. The bar's block prints that line.
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate to
// DrawFile, too coarse to leak anything. That file is this module's alone: its bundle reads it and puts
// the reading on the bus as the state `draw`, keyed by the machine (publish.go), and nothing of another
// module reads it (novox/hq ADR 0260: modules talk over the bus).
// DrawFile is where the rate is published, and SampleEvery how often.
// DrawFile is where the sampler leaves the reading for the module's bundle, and SampleEvery how often.
const (
DrawFile = "/run/mesh-power/draw"
SampleEvery = 5 * time.Second
@@ -40,6 +43,30 @@ type Draw struct {
GPU *float64
}
// Reading is a draw as the state `draw` holds it: the line shown, the watts, what they cover and when.
type Reading struct {
Text string `json:"text"`
Watts float64 `json:"watts,omitempty"`
Measures string `json:"measures,omitempty"`
At string `json:"at"`
}
// Reading is the draw as it is published, at a moment.
func (d Draw) Reading(at time.Time) Reading {
r := Reading{Text: d.Line(), At: at.UTC().Format(time.RFC3339)}
switch {
case d.Machine != nil:
r.Watts, r.Measures = *d.Machine, "machine"
case d.CPU != nil && d.GPU != nil:
r.Watts, r.Measures = *d.CPU+*d.GPU, "cpu+gpu"
case d.CPU != nil:
r.Watts, r.Measures = *d.CPU, "cpu"
case d.GPU != nil:
r.Watts, r.Measures = *d.GPU, "gpu"
}
return r
}
// Line is the reading as the bar shows it.
func (d Draw) Line() string {
switch {
@@ -221,8 +248,13 @@ func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(tim
return Draw{CPU: cpuWatts(before, m.packageCounters(), time.Since(start)), GPU: m.gpuWatts(ctx)}
}
// Publish writes the line where the bar reads it: whole, readable by anyone, replacing the last.
func (m *Machine) Publish(line string) error {
// Publish writes the reading where the module's bundle reads it, whole, replacing the last.
func (m *Machine) Publish(r Reading) error {
raw, err := json.Marshal(r)
if err != nil {
return err
}
line := string(raw)
path := m.path(DrawFile)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
@@ -244,7 +276,7 @@ func RunDraw(ctx context.Context, m *Machine) error {
// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval
// itself; a counter reading waits inside its sample. It returns as soon as the context ends.
func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(string) error) error {
func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(Reading) error) error {
wait := func(d time.Duration) bool {
select {
case <-ctx.Done():
@@ -258,7 +290,7 @@ func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(
if ctx.Err() != nil {
break
}
if err := publish(d.Line()); err != nil {
if err := publish(d.Reading(time.Now())); err != nil {
return fmt.Errorf("publishing the draw: %w", err)
}
if d.Machine != nil && !wait(every) {
+5 -34
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -124,9 +123,10 @@ func TestNothingToMeasureIsSaid(t *testing.T) {
}
}
func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) {
m := tree(t, nil)
if err := m.Publish("8.0 W CPU"); err != nil {
w := 8.0
if err := m.Publish(Draw{CPU: &w}.Reading(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC))); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(m.Root, DrawFile))
@@ -134,7 +134,7 @@ func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
t.Fatalf("%v %v", info, err)
}
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
if string(b) != "8.0 W CPU\n" {
if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" {
t.Fatalf("%q", b)
}
}
@@ -191,7 +191,7 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
var lines []string
done := make(chan error)
go func() {
done <- runDraw(ctx, m, 20*time.Millisecond, func(l string) error { lines = append(lines, l); return nil })
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
}()
time.Sleep(210 * time.Millisecond)
cancel()
@@ -208,32 +208,3 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
t.Fatalf("%d lines: %v", len(lines), lines)
}
}
func TestTheBarsScriptShowsOnlyAFreshLine(t *testing.T) {
file := filepath.Join(t.TempDir(), "draw")
run := func() string {
cmd := exec.Command("sh", "../../files/bin/power-draw")
cmd.Env = append(os.Environ(), "MESH_POWER_DRAW="+file)
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(out))
}
if got := run(); got != "power not measured" {
t.Fatalf("no file: %q", got)
}
if err := os.WriteFile(file, []byte("8.0 W CPU\n"), 0o644); 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(file, old, old); err != nil {
t.Fatal(err)
}
if got := run(); got != "power not measured" {
t.Fatalf("31 s old: %q", got)
}
}
+2
View File
@@ -35,6 +35,8 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
// And the power drawn, from the module's own sampler onto the bus (novox/hq ADR 0260).
go ShareDraw(m)
if err := stdio.Serve("", Tools(m, w)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The draw on the bus (novox/hq ADR 0260). The module's bundle runs as the operator's account and so
// cannot read the energy counter; its root process can and cannot reach the bus. So the process leaves
// each reading in the module's own file and the bundle puts it in the state `draw`, keyed by this
// machine: the current value, which a machine reading later sees at once. Not an event: a reading is
// replaced every five seconds and nobody needs the ones before, and an event would keep each in a stream.
// Nothing measured is not put; a value older than its lifetime (`ttl-seconds` in the manifest) is gone.
// DrawState is the state the draw is kept in, and DrawStale how old a reading may be and still be put.
const (
DrawState = "draw"
DrawStale = 30 * time.Second
)
// Putter puts one key of a state; the SDK's in the bundle, a recorder in a test.
type Putter func(key string, value any) error
// shareDraw reads the sampler's reading and puts it for this machine, if it is fresh and measured
// something. It answers what it did, for the log.
func (m *Machine) shareDraw(machine string, now time.Time, put Putter) (string, error) {
raw, err := os.ReadFile(m.path(DrawFile))
if err != nil {
return "no reading yet", nil
}
var r Reading
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("the sampler's reading is not one: %w", err)
}
at, err := time.Parse(time.RFC3339, r.At)
if err != nil || now.Sub(at) > DrawStale {
return "the sampler's reading is stale", nil
}
if r.Measures == "" {
return "nothing measured", nil
}
if err := put(machine, r); err != nil {
return "", err
}
return "put " + r.Text, nil
}
// ShareDraw puts the draw every SampleEvery for as long as the bundle runs. A machine with no name
// from the runtime puts nothing, and says so once.
func ShareDraw(m *Machine) {
machine := os.Getenv("MESH_NODE")
if machine == "" {
fmt.Fprintln(os.Stderr, "power: the runtime gave no machine name (MESH_NODE); the draw is not shared")
return
}
put := func(key string, value any) error {
_, err := stdio.State(DrawState).Put(key, value)
return err
}
last := ""
for range time.Tick(SampleEvery) {
said, err := m.shareDraw(machine, time.Now(), put)
if err != nil {
said = err.Error()
}
if said != last && (err != nil || !strings.HasPrefix(said, "put ")) {
fmt.Fprintln(os.Stderr, "power: draw:", said)
}
last = said
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// Defends novox/hq ADR 0260: the draw reaches the bar over the bus, as this module's state keyed by the
// machine, and only a fresh reading that measured something is put.
func TestAFreshReadingIsPutForThisMachine(t *testing.T) {
m := tree(t, nil)
now := time.Date(2026, 10, 8, 12, 0, 10, 0, time.UTC)
w := 18.2
if err := m.Publish(Draw{Machine: &w}.Reading(now.Add(-5 * time.Second))); err != nil {
t.Fatal(err)
}
var key string
var value Reading
said, err := m.shareDraw("laptop", now, func(k string, v any) error { key, value = k, v.(Reading); return nil })
if err != nil || key != "laptop" || value.Text != "18.2 W machine" || value.Measures != "machine" ||
!strings.HasPrefix(said, "put ") {
t.Fatalf("%q %v: put %q %+v", said, err, key, value)
}
}
func TestAStaleOrEmptyReadingIsNotPut(t *testing.T) {
m := tree(t, nil)
now := time.Date(2026, 10, 8, 12, 1, 0, 0, time.UTC)
never := func(string, any) error { t.Fatal("put"); return nil }
if said, _ := m.shareDraw("laptop", now, never); said != "no reading yet" {
t.Fatalf("%q", said)
}
w := 9.0
if err := m.Publish(Draw{CPU: &w}.Reading(now.Add(-time.Minute))); err != nil {
t.Fatal(err)
}
if said, _ := m.shareDraw("laptop", now, never); said != "the sampler's reading is stale" {
t.Fatalf("%q", said)
}
if err := m.Publish(Draw{}.Reading(now)); err != nil {
t.Fatal(err)
}
if said, _ := m.shareDraw("laptop", now, never); said != "nothing measured" {
t.Fatalf("%q", said)
}
}
func TestARefusedPutIsSaid(t *testing.T) {
m := tree(t, nil)
now := time.Now()
w := 9.0
if err := m.Publish(Draw{CPU: &w}.Reading(now)); err != nil {
t.Fatal(err)
}
if _, err := m.shareDraw("laptop", now, func(string, any) error { return errors.New("refused") }); err == nil {
t.Fatal("a refused put was taken for one done")
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# power-draw (module power, novox/hq ADR 0255): the power the machine draws, for the bar's block.
#
# Prints the line the module's sampler (`power draw`, as root) publishes every five seconds, which says
# what it measured: "machine" on battery, "CPU" or "CPU+GPU" otherwise. A line older than thirty
# seconds is a sampler that stopped, and is not shown as if it were now.
f="${MESH_POWER_DRAW:-/run/mesh-power/draw}"
if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then
head -n 1 "$f"
else
echo "power not measured"
fi
+14 -3
View File
@@ -20,6 +20,12 @@
"on-battery",
"battery-low"
],
"state": [
{
"name": "draw",
"ttl-seconds": 30
}
],
"tools": [
"power_state",
"power_hooks",
@@ -235,6 +241,12 @@
"state": "running",
"boot": "enabled"
},
{
"id": "draw-dir",
"type": "directory",
"path": "/run/mesh-power",
"mode": "0755"
},
{
"id": "draw",
"type": "process",
@@ -289,10 +301,9 @@
"bar": "bottom",
"place": "status",
"order": 45,
"shows": "command",
"shows": "state",
"options": {
"command": "/usr/local/lib/mesh-power/bin/power-draw",
"interval": 5
"state": "power.draw"
}
}
}