From 0040c008b563a5b25ed5d83b3257d27be88ff6c0 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 13:42:47 +0200 Subject: [PATCH 1/6] Let the bar render blocks other modules contribute, and give it the battery and power draw from the power module (hq ADR 0255) The bar left the battery out because it belongs to whoever knows the hardware, and had no way to take it back in. i3status-rust now renders bar-neutral blocks through its template; the power module contributes the battery where the machine reports one and the power draw where something measures it, sampling the root-only energy counter as root and publishing only a rate. The memory block shows what programs hold, the figure memory-pressure judges by, instead of counting the cache as used. --- modules/asus-zephyrus-g14/README.md | 10 +- modules/i3status-rust/README.md | 40 +-- .../cmd/i3status-rust-tools/blocks_test.go | 98 +++++++ .../manifest_helpers_test.go | 2 + .../cmd/i3status-rust-tools/manifest_test.go | 3 +- modules/i3status-rust/files/block.tmpl | 20 ++ modules/i3status-rust/files/bottom-bar.toml | 15 +- modules/i3status-rust/files/top-bar.toml | 4 + modules/i3status-rust/module.json | 9 +- modules/power/README.md | 29 +++ modules/power/cmd/power/draw.go | 244 ++++++++++++++++++ modules/power/cmd/power/draw_test.go | 137 ++++++++++ modules/power/cmd/power/main.go | 18 ++ modules/power/files/bin/power-draw | 12 + modules/power/module.json | 43 ++- 15 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go create mode 100644 modules/i3status-rust/files/block.tmpl create mode 100644 modules/power/cmd/power/draw.go create mode 100644 modules/power/cmd/power/draw_test.go create mode 100755 modules/power/files/bin/power-draw diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index 2d6f69ba..05efa63c 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -58,9 +58,11 @@ daemons and probably the same keys. Their GPU options are unverified. handling. Until then it stays as found, and it still calls the predecessor's `as-user`. - **The screenshot script** (`~/.config/i3/scripts/screenshot.sh`) is any machine's. The `i3` module binds it too. This module only binds the key the firmware sends for it. -- **The bar's battery block.** It belongs here (a block that follows this model's hardware), but the - bar has no way in yet (`i3status-rust` README). Once ADR 0210's contributions reach the bar seat, - this module contributes it. +- **The bar's battery block, and the power draw's.** A battery is any laptop's, not this model's, so + the `power` module contributes both blocks wherever the machine reports a battery or a power meter + (novox/hq ADR 0255). This module would contribute only what is this model's alone, such as the charge + limit asusd holds; nothing yet. The predecessor's AMD GPU block is no model's either, and waits for a + generic module (the `i3status-rust` README). **Requires `x11-display`.** The i3 lines and the session scripts need a display, so the module is assigned where the display server is. @@ -306,7 +308,7 @@ The predecessor carried this model in its desktop module's `g14` and `laptop` fl | `g14-90-backlight.rules` | **this module**, the same path | | `g14-logind-brightness.conf` | **retired**: an unknown logind key that did nothing | | `laptop-monitor-wizard.sh`, `laptop-91-monitor-hotplug.rules` | **any laptop's, not this module's**: kept as found, for the `xorg` module's autorandr to replace | -| `bottom-bar.g14.toml` (the battery block) | **waits**: this module's, once the bar takes contributions (ADR 0210) | +| `bottom-bar.g14.toml` (the battery block) | **the `power` module's**: any machine with a battery gets it (ADR 0255) | | `razer-basilisk-battery-percentage` | **not this model's**: a mouse. No module carries it (`i3status-rust` README) | | `screenshot.sh` | **not this model's**: any machine's script, which the `i3` module binds too; this module binds Fn+F6 to it | | package `triggerhappy` | **depended on, kept as found** (outside the distribution, above) | diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 70a19c75..ad706a85 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -3,7 +3,8 @@ The bars as a module (novox/hq ADR 0208, research 026/05). - Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition. - Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2). Requires `x11-display` on its own + Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules + contribute to it (ADR 0255, below). Requires `x11-display` on its own machine: its bars are i3bar's. - Owns `~/.config/i3status-rust/`, both bars (`top-bar.toml`, `bottom-bar.toml`) and their icon set (`icons/custom-icons.toml`). @@ -25,25 +26,30 @@ The bars as a module (novox/hq ADR 0208, research 026/05). | `i3status_rust_block_run` | one block alone, with the bar's theme and icons, to see what it shows or why it errors | | `i3status_rust_themes` | the themes on the machine and the one each bar uses | -## The battery, and what else was left out +## Blocks other modules contribute (novox/hq ADR 0255) -A bar block that follows one machine's hardware is that machine model's hardware module's (ADR 0208 -§1), so the bottom bar here has none: +A block that depends on the machine's hardware is not written here. The module that knows the hardware +contributes it to `node-bar` as data that says what it shows, and this module renders it: -- **the battery,** which only the laptop has; -- **the GPU,** which was the laptop's AMD block, commented out for NVIDIA on the desktop; -- **three Bluetooth headsets** by hardware address: one person's devices, not the bar's. +- **What a contribution says:** which bar (`bottom`, `top`), which place (`resources`, beside the + processor, memory and disks; `status`, beside the sound), an order from 0 to 99, what it shows + (`battery`, `command`) and that kind's options. Nothing in it is i3status-rust's. +- **How it is rendered:** the claim's `renders.block` is a template (its source is `files/block.tmpl`) + that turns one piece into a `[[block]]`. The controller runs it and places the result where each bar + says `${contribution:node-bar:block:bar=…,place=…}`. Each bar places both of its places once; the + catalogue check refuses a holder that misses one. +- **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. -i3status-rust reads no drop-in directory, and ADR 0208's slots cover `xinitrc` and `xresources` only, -so a hardware module has no way into this file yet. Two ways forward, for the operator to choose: +Still left out: -1. **Give the bar a slot.** A `shell`-style contribution `for: i3status-rust` would let the laptop's - hardware module add its battery block. That is a decision record of its own, extending ADR 0208 §4. -2. **Blocks that show only where they apply.** i3status-rust's common `if_command` option (for - example `test -d /sys/class/power_supply/BAT0`) hides a block on a machine without the hardware. - That is "say it by what is there" (ADR 0112), but the knowledge stays in the bar. +- **The GPU's load,** the laptop's AMD block (commented out for NVIDIA on the desktop). A generic GPU + block belongs to a generic module, which does not exist yet; a GPU's power is in the power draw. +- **Three Bluetooth headsets** by hardware address: one person's devices, not the bar's. -Until one of them is chosen, **the laptop's bar shows no battery** once this module is assigned. +The second way the README once offered, blocks with i3status-rust's `if_command` that hide themselves on +a machine without the hardware, was rejected: the knowledge would stay in the bar, decided at run time. ## What it improves on what was found @@ -53,6 +59,9 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod - **The update count is the module's own,** adopted from the operator's `~/scripts/pkg-updates`. It counts AUR updates only where an AUR helper is installed. Clicking it lists the updates in the launcher's menu, instead of a theme from a cloned theme repository. +- **The memory is what programs hold** (`$mem_used`), not `$mem_total_used`, which counts the cache the + kernel gives back when asked and read 96 % on a machine at 73 %. It is the figure the + `memory-pressure` module judges by (changed 2026-10-08). - **The faces** are JetBrains Mono Nerd Font, not Hack. - **The watchdog is no longer a hand-enabled user unit** that ran on one workstation and not the other. It runs on both, once per session, and ends with the session. @@ -79,7 +88,6 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod - `node-bar`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads them as unknown. -- The battery block waits for one of the two ways above. - The watchdog would be a user unit once user-scoped units ship (mesh-host #72). It is not, because it runs per session and ends with the session, as a session-start line already does. - `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go new file mode 100644 index 00000000..83fc876b --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "text/template" +) + +// i3status-rust renders the blocks other modules contribute (novox/hq ADR 0255): a bar-neutral piece +// — what it shows, on which bar, where — in its own grammar, through the template its claim gives. The +// controller runs the template; these tests run it the same way, with the one function it is given. + +func blockTemplate(t *testing.T) *template.Template { + t.Helper() + m := readManifest(t) + src, err := os.ReadFile("../../files/block.tmpl") + if err != nil { + t.Fatal(err) + } + if m.Claims[0].Renders["block"] != string(src) { + t.Fatal("the claim's template is not files/block.tmpl") + } + quote := func(v any) (string, error) { + b, err := json.Marshal(fmt.Sprint(v)) + return string(b), err + } + return template.Must(template.New("block").Funcs(template.FuncMap{"quote": quote}).Parse(string(src))) +} + +func render(t *testing.T, tmpl *template.Template, piece string) string { + t.Helper() + view := map[string]any{"module": "power"} + if err := json.Unmarshal([]byte(piece), &view); err != nil { + t.Fatal(err) + } + var b bytes.Buffer + if err := tmpl.Execute(&b, view); err != nil { + t.Fatal(err) + } + if strings.Contains(b.String(), "") { + t.Fatalf("%s rendered a missing value: %q", piece, b.String()) + } + return b.String() +} + +func TestAContributedBatteryIsI3statusRustsBatteryBlock(t *testing.T) { + tmpl := blockTemplate(t) + got := render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery"}`) + if !strings.HasPrefix(got, "[[block]]\nblock = \"battery\"\nformat = ") || strings.Contains(got, "device") || + !strings.Contains(got, `missing_format = ""`) || !strings.HasSuffix(got, "\n\n") { + t.Fatalf("%q", got) + } + got = render(t, tmpl, `{"bar":"bottom","place":"status","shows":"battery","options":{"device":"BAT1"}}`) + if !strings.Contains(got, "block = \"battery\"\ndevice = \"BAT1\"\nformat = ") { + t.Fatalf("%q", got) + } +} + +func TestAContributedCommandIsACustomBlockQuoted(t *testing.T) { + got := render(t, blockTemplate(t), + `{"bar":"bottom","place":"status","shows":"command","options":{"command":"say \"hi\"","interval":5}}`) + want := "[[block]]\nblock = \"custom\"\ncommand = \"say \\\"hi\\\"\"\ninterval = 5\n\n" + if got != want { + t.Fatalf("%q, want %q", got, want) + } +} + +func TestEachBarPlacesBothPlacesOnce(t *testing.T) { + m := readManifest(t) + for _, bar := range []string{"bottom", "top"} { + content := m.resource(t, bar+"-bar")["content"].(string) + for _, place := range []string{"resources", "status"} { + ph := "${contribution:node-bar:block:bar=" + bar + ",place=" + place + "}" + if strings.Count(content, ph) != 1 { + t.Errorf("the %s bar places %s %d times", bar, ph, strings.Count(content, ph)) + } + } + } + bottom := m.resource(t, "bottom-bar")["content"].(string) + resources := strings.Index(bottom, "place=resources") + if !(strings.Index(bottom, `block = "memory"`) < resources && resources < strings.Index(bottom, `block = "docker"`)) || + !(strings.Index(bottom, `block = "sound"`) < strings.Index(bottom, "place=status") && + strings.Index(bottom, "place=status") < strings.Index(bottom, `block = "weather"`)) { + t.Fatal("the places are not beside the memory and beside the sound") + } +} + +func TestTheMemoryIsWhatProgramsHoldNotTheCache(t *testing.T) { + bottom := readManifest(t).resource(t, "bottom-bar")["content"].(string) + if !strings.Contains(bottom, `format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))"`) || + strings.Contains(bottom, "mem_total_used") { + t.Fatal("the memory block counts the cache as used") + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go index 441415f9..5c76cde9 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -33,6 +33,8 @@ type claim struct { Name string `json:"name"` Scope string `json:"scope"` Serves []string `json:"serves"` + // How the holder renders each kind its seat receives as data (novox/hq ADR 0255). + Renders map[string]string `json:"renders"` } type environment struct { diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index 0acfefa2..a39b1d72 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -16,7 +16,8 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) { if m.Module != "i3status-rust" || m.Seats != nil { t.Fatalf("module %q declares seats %v", m.Module, m.Seats) } - if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-bar", Scope: "node"}}) { + if len(m.Claims) != 1 || m.Claims[0].Name != "node-bar" || m.Claims[0].Scope != "node" || + len(m.Claims[0].Renders) != 1 || m.Claims[0].Renders["block"] == "" { t.Fatalf("claims: %+v", m.Claims) } if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl new file mode 100644 index 00000000..befa9c22 --- /dev/null +++ b/modules/i3status-rust/files/block.tmpl @@ -0,0 +1,20 @@ +{{- /* 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. */ -}} +{{- if eq .shows "battery" -}} +[[block]] +block = "battery" +{{with .options}}{{with .device}}device = {{quote .}} +{{end}}{{end -}} +format = "$icon $percentage{ $time|}" +charging_format = "$icon $percentage{ $time|}" +not_charging_format = "$icon $percentage" +full_format = "$icon $percentage" +empty_format = "$icon $percentage" +missing_format = "" +{{- else if eq .shows "command" -}} +[[block]] +block = "custom" +command = {{quote .options.command}} +interval = {{.options.interval}} +{{- end}} + diff --git a/modules/i3status-rust/files/bottom-bar.toml b/modules/i3status-rust/files/bottom-bar.toml index 80980938..58ef2029 100644 --- a/modules/i3status-rust/files/bottom-bar.toml +++ b/modules/i3status-rust/files/bottom-bar.toml @@ -1,7 +1,8 @@ # The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every # push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one -# person's devices left out: the battery and the GPU belong to a machine model's hardware module, -# and a headset's address is not the bar's to know. Every block here works the same on any machine. +# person's devices left out: a block that depends on the hardware is contributed by the module that +# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written +# here works the same on any machine. icons_format = "{icon}" [theme] @@ -66,9 +67,14 @@ format = "$icon /home $used.eng(w:2) ($percentage.eng(w:2))" [[block]] block = "memory" -format = "$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))" +# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure +# module judges by. Counting the cache as used read near full on a machine with room to spare. +format = "$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))" format_alt = "$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))" +# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from +# what they show by this module's template, and only on a machine that has what they show. +${contribution:node-bar:block:bar=bottom,place=resources} [[block]] block = "docker" interval = 2 @@ -80,6 +86,9 @@ block = "sound" button = "left" cmd = "pavucontrol" +# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and +# power draw, where the machine has them. +${contribution:node-bar:block:bar=bottom,place=status} # The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is. [[block]] block = "weather" diff --git a/modules/i3status-rust/files/top-bar.toml b/modules/i3status-rust/files/top-bar.toml index 11660368..b767a765 100644 --- a/modules/i3status-rust/files/top-bar.toml +++ b/modules/i3status-rust/files/top-bar.toml @@ -31,3 +31,7 @@ short = " $title.str(max_w:100) |" [[block]] block = "uptime" interval = 60 + +# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept +# so that one that does is never dropped. +${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status} diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index ad647882..d6a5706f 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -10,7 +10,10 @@ "claims": [ { "name": "node-bar", - "scope": "node" + "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" + } } ], "tools": [ @@ -52,7 +55,7 @@ "path": "${machine:account-home}/.config/i3status-rust/top-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n" + "content": "# The top bar (module i3status-rust, novox/hq ADR 0208): the focused window's title and the uptime.\n# Owned by the mesh: replaced at every push. Adopted unchanged from the workstations of 2026-10-04.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"focused_window\"\n[block.format]\nfull = \" $title.str(max_w:50) |\"\nshort = \" $title.str(max_w:100) |\"\n\n[[block]]\nblock = \"uptime\"\ninterval = 60\n\n# Blocks other modules contribute to the top bar (novox/hq ADR 0255). None do yet; both places are kept\n# so that one that does is never dropped.\n${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}\n" }, { "id": "bottom-bar", @@ -60,7 +63,7 @@ "path": "${machine:account-home}/.config/i3status-rust/bottom-bar.toml", "owner": "${machine:account}", "mode": "0644", - "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: the battery and the GPU belong to a machine model's hardware module,\n# and a headset's address is not the bar's to know. Every block here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\nformat = \"$icon $mem_total_used.eng(w:2) ($mem_total_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" + "content": "# The bottom bar (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. Adopted from the workstations of 2026-10-04 with what follows a machine's hardware or one\n# person's devices left out: a block that depends on the hardware is contributed by the module that\n# knows it (novox/hq ADR 0255), and a headset's address is not the bar's to know. Every block written\n# here works the same on any machine.\nicons_format = \"{icon}\"\n\n[theme]\ntheme = \"plain\"\n[theme.overrides]\nidle_bg = \"#000000\"\nidle_fg = \"#f1f1f1\"\ninfo_bg = \"#000000\"\ninfo_fg = \"#f1f1f1\"\ngood_bg = \"#000000\"\ngood_fg = \"#859900\"\nwarning_bg = \"#000000\"\nwarning_fg = \"#de5200\"\ncritical_bg = \"#000000\"\ncritical_fg = \"#ff0000\"\nseparator = \" | \"\nseparator_fg = \"#ffffff\"\nseparator_bg = \"#000000\"\n\n[icons]\nicons = \"custom-icons\"\n\n[[block]]\nblock = \"tea_timer\"\nformat = \"$icon{ $minutes:$seconds|}\"\ndone_cmd = \"notify-send 'Timer finished'\"\n\n# Pending updates: official and, where an AUR helper is installed, AUR. The script keeps the last good\n# count through a network gap and shows an error only after ten failed checks in a row. Click: the\n# list, in the launcher's menu.\n[[block]]\nblock = \"custom\"\ncommand = \"~/.local/bin/i3status-updates 10\"\njson = true\ninterval = 1800\n[[block.click]]\nbutton = \"left\"\ncmd = \"checkupdates | dmenu -l 20 -p Updates\"\n\n[[block]]\nblock = \"cpu\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon / $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"disk_space\"\npath = \"/home\"\ninfo_type = \"used\"\nalert_unit = \"GB\"\ninterval = 20\nwarning = 850\nalert = 920\nformat = \"$icon /home $used.eng(w:2) ($percentage.eng(w:2))\"\n\n[[block]]\nblock = \"memory\"\n# What programs hold, without the cache the kernel gives back when asked: the figure the memory-pressure\n# module judges by. Counting the cache as used read near full on a machine with room to spare.\nformat = \"$icon $mem_used.eng(w:2) ($mem_used_percents.eng(w:2))\"\nformat_alt = \"$icon_swap $swap_used.eng(w:2) ($swap_used_percents.eng(w:2))\"\n\n# Blocks other modules contribute beside the machine's resources (novox/hq ADR 0255), rendered from\n# what they show by this module's template, and only on a machine that has what they show.\n${contribution:node-bar:block:bar=bottom,place=resources}\n[[block]]\nblock = \"docker\"\ninterval = 2\nformat = \"$icon $running/$total\"\n\n[[block]]\nblock = \"sound\"\n[[block.click]]\nbutton = \"left\"\ncmd = \"pavucontrol\"\n\n# Blocks other modules contribute beside the sound (novox/hq ADR 0255): the power module's battery and\n# power draw, where the machine has them.\n${contribution:node-bar:block:bar=bottom,place=status}\n# The weather from the Norwegian Meteorological Institute, which needs no key, where the machine is.\n[[block]]\nblock = \"weather\"\nformat = \"$icon $weather_verbose ($location) $temp\"\nautolocate = true\nautolocate_interval = 600\n[block.service]\nname = \"metno\"\n\n[[block]]\nblock = \"notify\"\ndriver = \"dunst\"\nformat = \" $icon {($notification_count.eng(w:1))|}\"\n\n[[block]]\nblock = \"time\"\ninterval = 1\nformat = \"$timestamp.datetime(f:'%a %d/%m %H:%M:%S')\"\n" }, { "id": "icons", diff --git a/modules/power/README.md b/modules/power/README.md index f85d44ad..d43bb803 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -13,6 +13,7 @@ states on the bus. | `/etc/mesh-power/moments/` | for each moment, every module's code as the controller placed it (`${shell::}`) | | `/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) | | `/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 @@ -31,6 +32,34 @@ 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 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 +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` | + +The power draw says what it measured: + +- **on battery**, the battery's discharge: `18.2 W machine`; +- **otherwise**, the processor package's power from the kernel's powercap energy counter (RAPL, Intel + and AMD), plus a discrete graphics device's own power where it reports one: `14.0 W CPU+GPU` or + `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 an NVIDIA device through `nvidia-smi` only while it is awake, so the +bar never wakes a sleeping GPU. + ## Settings `handle-power-key`, `handle-lid-switch`, `handle-lid-switch-external-power` and diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go new file mode 100644 index 00000000..280bb1cf --- /dev/null +++ b/modules/power/cmd/power/draw.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// The power the machine draws, measured honestly and said with what was measured (novox/hq ADR 0255). +// +// - **On battery,** the battery's discharge rate. That is the whole machine. +// - **Otherwise** — on mains, or on a machine without a battery — the processor package's power from +// the kernel's powercap energy counter (RAPL, on Intel and AMD alike: a rate from the counter's +// change), plus a discrete graphics device's own power where it reports one. That is the processor +// (and graphics) part, never called the machine's. +// - **The machine at the wall** needs a metered plug or a UPS, and is not measured here. +// +// 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. + +// DrawFile is where the rate is published, and SampleEvery how often. +const ( + DrawFile = "/run/mesh-power/draw" + SampleEvery = 5 * time.Second +) + +// Draw is one reading: each part measured, in watts, and what it covers. +type Draw struct { + // Machine is the battery's discharge, when on battery: the whole machine. + Machine *float64 + // CPU is the processor packages' power, from the energy counters. + CPU *float64 + // GPU is the discrete graphics devices' own power, where they report it; asleep counts as none. + GPU *float64 +} + +// Line is the reading as the bar shows it. +func (d Draw) Line() string { + switch { + case d.Machine != nil: + return fmt.Sprintf("%.1f W machine", *d.Machine) + case d.CPU != nil && d.GPU != nil: + return fmt.Sprintf("%.1f W CPU+GPU", *d.CPU+*d.GPU) + case d.CPU != nil: + return fmt.Sprintf("%.1f W CPU", *d.CPU) + case d.GPU != nil: + return fmt.Sprintf("%.1f W GPU", *d.GPU) + } + return "power not measured" +} + +// counter is one energy counter's reading, in microjoules, with the range it wraps at. +type counter struct { + uj, max int64 +} + +// packageCounters is every processor package's energy counter, by its directory. +func (m *Machine) packageCounters() map[string]counter { + out := map[string]counter{} + for _, dir := range m.glob("/sys/class/powercap/*") { + if !strings.HasPrefix(m.read(dir+"/name"), "package") { + continue + } + uj, ok := m.readInt(dir + "/energy_uj") + if !ok { + continue + } + max, _ := m.readInt(dir + "/max_energy_range_uj") + out[dir] = counter{uj: uj, max: max} + } + return out +} + +// cpuWatts is the packages' power between two readings `elapsed` apart; nil when no counter was read +// both times. A counter that wrapped is counted across its range. +func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 { + if elapsed <= 0 { + return nil + } + var joules float64 + read := false + for dir, a := range after { + b, ok := before[dir] + if !ok { + continue + } + delta := a.uj - b.uj + if delta < 0 { + if a.max <= 0 { + continue + } + delta += a.max + } + joules += float64(delta) / 1e6 + read = true + } + if !read { + return nil + } + w := joules / elapsed.Seconds() + return &w +} + +// batteryWatts is the system battery's discharge, when it is discharging; nil otherwise. +func (m *Machine) batteryWatts() *float64 { + var total float64 + found := false + for _, s := range m.Supplies() { + if s.Type != "Battery" || s.Device || s.Status != "Discharging" { + continue + } + dir := "/sys/class/power_supply/" + s.Name + if p, ok := m.readInt(dir + "/power_now"); ok { + total += float64(abs(p)) / 1e6 + found = true + continue + } + i, iok := m.readInt(dir + "/current_now") + v, vok := m.readInt(dir + "/voltage_now") + if iok && vok { + total += float64(abs(i)) * float64(v) / 1e12 + found = true + } + } + if !found { + return nil + } + return &total +} + +func abs(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// gpuWatts is the discrete graphics devices' own power, where they report it; nil when none does. +// +// - An AMD device's hwmon power counts only when it has its own memory (`mem_info_vram_vendor`): on +// an APU the integrated graphics report the whole package, which the processor's counter already +// holds, and counting it twice would be a lie. +// - An NVIDIA device is asked through nvidia-smi only while it is awake: asking wakes a suspended GPU, +// which on a hybrid laptop costs more than the answer is worth. Asleep, it draws next to nothing and +// counts as none. +func (m *Machine) gpuWatts(ctx context.Context) *float64 { + var total float64 + found := false + for _, card := range m.glob("/sys/class/drm/card[0-9]*") { + if strings.Contains(filepath.Base(card), "-") || !m.exists(card+"/device/mem_info_vram_vendor") { + continue + } + for _, hw := range m.glob(card + "/device/hwmon/hwmon*") { + uw, ok := m.readInt(hw + "/power1_average") + if !ok { + uw, ok = m.readInt(hw + "/power1_input") + } + if ok { + total += float64(uw) / 1e6 + found = true + break + } + } + } + for _, gpu := range m.glob("/proc/driver/nvidia/gpus/*") { + address := filepath.Base(gpu) + if m.read("/sys/bus/pci/devices/"+address+"/power/runtime_status") != "active" { + continue + } + out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=power.draw", "--format=csv,noheader,nounits", "-i", address) + if err != nil { + continue + } + if w, err := strconv.ParseFloat(strings.TrimSpace(out), 64); err == nil { + total += w + found = true + } + } + if !found { + return nil + } + return &total +} + +// Sample is one reading over `every`: the battery's discharge if the machine is on battery, else the +// processor's and the graphics' parts. +func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(time.Duration) bool) Draw { + if w := m.batteryWatts(); w != nil { + return Draw{Machine: w} + } + before, start := m.packageCounters(), time.Now() + if !wait(every) { + return Draw{} + } + 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 { + path := m.path(DrawFile) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".new" + if err := os.WriteFile(tmp, []byte(line+"\n"), 0o644); err != nil { + return err + } + if err := os.Chmod(tmp, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// RunDraw samples and publishes until the context ends. A battery reading is instant, so it waits +// the interval itself; a counter reading waits inside its sample. +func RunDraw(ctx context.Context, m *Machine) error { + wait := func(d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } + } + for ctx.Err() == nil { + d := m.Sample(ctx, SampleEvery, wait) + if ctx.Err() != nil { + break + } + if err := m.Publish(d.Line()); err != nil { + return fmt.Errorf("publishing the draw: %w", err) + } + if d.Machine != nil && !wait(SampleEvery) { + break + } + } + return nil +} diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go new file mode 100644 index 00000000..fa503e01 --- /dev/null +++ b/modules/power/cmd/power/draw_test.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Defends novox/hq ADR 0255: the power drawn is measured honestly and says what it measured — the +// machine on battery, the processor (and discrete graphics) otherwise — and only a rate leaves root. + +func tree(t *testing.T, files map[string]string) *Machine { + 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+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + return &Machine{Root: root, Run: func(context.Context, string, ...string) (string, error) { + return "", errors.New("nothing may be run here") + }} +} + +func noWait(time.Duration) bool { return true } + +func TestOnBatteryTheDischargeIsTheMachine(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT1/type": "Battery", + "/sys/class/power_supply/BAT1/status": "Discharging", + "/sys/class/power_supply/BAT1/current_now": "-1500000", + "/sys/class/power_supply/BAT1/voltage_now": "16000000", + // A counter is there, and not what a battery reading reports. + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "1", + }) + d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { t.Fatal("a battery reading waited"); return false }) + if got := d.Line(); got != "24.0 W machine" { + t.Fatalf("%q", got) + } +} + +func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT1/type": "Battery", + "/sys/class/power_supply/BAT1/status": "Not charging", + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "1000000", + "/sys/class/powercap/intel-rapl:0/max_energy_range_uj": "65532610987", + "/sys/class/powercap/intel-rapl:0:0/name": "core", + "/sys/class/powercap/intel-rapl:0:0/energy_uj": "1", + // An APU's integrated graphics report the whole package: not counted a second time. + "/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000", + }) + counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj") + d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { + return os.WriteFile(counter, []byte("11000000\n"), 0o644) == nil + }) + if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 { + t.Fatalf("%+v", d) + } + if !strings.HasSuffix(d.Line(), " W CPU") { + t.Fatalf("%q", d.Line()) + } +} + +func TestACounterThatWrappedIsCountedAcrossItsRange(t *testing.T) { + before := map[string]counter{"p": {uj: 90, max: 100}} + after := map[string]counter{"p": {uj: 10, max: 100}} + w := cpuWatts(before, after, time.Second) + if w == nil || *w != 20e-6 { + t.Fatalf("%v", w) + } + if cpuWatts(before, map[string]counter{"other": {uj: 1}}, time.Second) != nil { + t.Fatal("a counter read once was taken for a rate") + } +} + +func TestADiscreteGraphicsDevicesPowerIsAddedAndASleepingOneIsNotWoken(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung", + "/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000", + "/sys/class/drm/card0-DP-1/status": "connected", + "/proc/driver/nvidia/gpus/0000:01:00.0/information": "x", + "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status": "suspended", + }) + if w := m.gpuWatts(context.Background()); w == nil || *w != 30 { + t.Fatalf("%v", w) + } + // Awake, it is asked. + if err := os.WriteFile(filepath.Join(m.Root, "/sys/bus/pci/devices/0000:01:00.0/power/runtime_status"), []byte("active\n"), 0o644); err != nil { + t.Fatal(err) + } + m.Run = func(_ context.Context, name string, args ...string) (string, error) { + if name != "nvidia-smi" || args[len(args)-1] != "0000:01:00.0" { + t.Fatalf("ran %s %v", name, args) + } + return "12.50\n", nil + } + if w := m.gpuWatts(context.Background()); w == nil || *w != 42.5 { + t.Fatalf("%v", w) + } + cpu, gpu := 10.0, 42.5 + if got := (Draw{CPU: &cpu, GPU: &gpu}).Line(); got != "52.5 W CPU+GPU" { + t.Fatalf("%q", got) + } +} + +func TestNothingToMeasureIsSaid(t *testing.T) { + m := tree(t, map[string]string{"/sys/class/power_supply/ACAD/type": "Mains"}) + if got := m.Sample(context.Background(), time.Second, noWait).Line(); got != "power not measured" { + t.Fatalf("%q", got) + } +} + +func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) { + m := tree(t, nil) + if err := m.Publish("8.0 W CPU"); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(m.Root, DrawFile)) + if err != nil || info.Mode().Perm() != 0o644 { + t.Fatalf("%v %v", info, err) + } + b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile)) + if string(b) != "8.0 W CPU\n" { + t.Fatalf("%q", b) + } +} diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go index 1221ae74..ac1d55d0 100644 --- a/modules/power/cmd/power/main.go +++ b/modules/power/cmd/power/main.go @@ -1,17 +1,35 @@ // The power module's Go bundle (novox/hq ADR 0211, ADR 0198): one process the node's runtime // launches, serving the module's tools over MCP on stdio and running its watcher beside them, which // publishes the machine's power states on the bus. +// +// Started as `power draw` it is the module's other process instead: the sampler that publishes the +// power the machine draws for the bar, as root, because the energy counter is root's (draw.go). package main import ( "context" "fmt" "os" + "os/signal" + "syscall" stdio "git.novox.be/novox/mesh-sdk/go" ) func main() { + if len(os.Args) > 1 { + if os.Args[1] != "draw" || len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: power [draw]") + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) + defer stop() + if err := RunDraw(ctx, Here()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } m := Here() w := NewWatcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }, DialLogin1) ctx, cancel := context.WithCancel(context.Background()) diff --git a/modules/power/files/bin/power-draw b/modules/power/files/bin/power-draw new file mode 100755 index 00000000..58430300 --- /dev/null +++ b/modules/power/files/bin/power-draw @@ -0,0 +1,12 @@ +#!/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=/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 diff --git a/modules/power/module.json b/modules/power/module.json index 12ac72d8..794c857b 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -234,6 +234,19 @@ "unit": "mesh-power-before-shutdown.service", "state": "running", "boot": "enabled" + }, + { + "id": "draw", + "type": "process", + "name": "mesh-power-draw", + "artifact": "tools-go", + "run": [ + "./power", + "draw" + ], + "health": { + "kind": "unit" + } } ], "build": { @@ -255,5 +268,33 @@ "from": "files" } ] - } + }, + "contributions": [ + { + "seat": "node-bar", + "kind": "block", + "if-capability": "battery", + "data": { + "bar": "bottom", + "place": "status", + "order": 40, + "shows": "battery" + } + }, + { + "seat": "node-bar", + "kind": "block", + "if-capability": "power-meter", + "data": { + "bar": "bottom", + "place": "status", + "order": 45, + "shows": "command", + "options": { + "command": "/usr/local/lib/mesh-power/bin/power-draw", + "interval": 5 + } + } + } + ] } From e1b7fcd8c9a397821309d77f5bf12fd5ca732e51 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 14:34:45 +0200 Subject: [PATCH 2/6] Count each processor package once, never read a sleeping card, and give no reading for a reset counter (hq ADR 0255) Intel shows some packages twice (MSR and MMIO), an AMD card's sensors wake it, and a counter reset after resume read as thousands of watts. The loop's cadence and the bar script's staleness are now tested, and the sampler running on every machine is said. --- modules/power/README.md | 11 ++- modules/power/cmd/power/draw.go | 43 ++++++++--- modules/power/cmd/power/draw_test.go | 104 ++++++++++++++++++++++++++- modules/power/files/bin/power-draw | 2 +- 4 files changed, 147 insertions(+), 13 deletions(-) diff --git a/modules/power/README.md b/modules/power/README.md index d43bb803..3973605b 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -57,8 +57,15 @@ module's process `mesh-power-draw` (`power draw`, as root) samples every 5 s and 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 an NVIDIA device through `nvidia-smi` only while it is awake, so the -bar never wakes a sleeping GPU. +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. + +**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 +to condition a resource on another seat (the bar) being held on the machine. It costs one process +reading a few kernel files every five seconds (novox/hq ADR 0255 §9). ## Settings diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go index 280bb1cf..fa7206bd 100644 --- a/modules/power/cmd/power/draw.go +++ b/modules/power/cmd/power/draw.go @@ -60,11 +60,22 @@ type counter struct { uj, max int64 } -// packageCounters is every processor package's energy counter, by its directory. +// MaxPackageWatts is more than any processor package draws. A rate above it is a counter that was +// reset — after a resume, say — not power, and that sample gives no reading. +const MaxPackageWatts = 1000 + +// packageCounters is every processor package's energy counter, by the package's name, each counted +// once. Intel exposes some packages twice, through MSRs (`intel-rapl:N`) and through MMIO +// (`intel-rapl-mmio:N`), under one name; the MSR one is read, and the second never adds to it. func (m *Machine) packageCounters() map[string]counter { out := map[string]counter{} + from := map[string]string{} for _, dir := range m.glob("/sys/class/powercap/*") { - if !strings.HasPrefix(m.read(dir+"/name"), "package") { + name := m.read(dir + "/name") + if !strings.HasPrefix(name, "package") { + continue + } + if was, seen := from[name]; seen && !strings.Contains(filepath.Base(was), "mmio") { continue } uj, ok := m.readInt(dir + "/energy_uj") @@ -72,13 +83,15 @@ func (m *Machine) packageCounters() map[string]counter { continue } max, _ := m.readInt(dir + "/max_energy_range_uj") - out[dir] = counter{uj: uj, max: max} + out[name] = counter{uj: uj, max: max} + from[name] = dir } return out } // cpuWatts is the packages' power between two readings `elapsed` apart; nil when no counter was read -// both times. A counter that wrapped is counted across its range. +// both times. A counter that wrapped is counted across its range; one that gives a rate no package +// draws was reset, and the sample gives nothing rather than a number that is not power. func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 { if elapsed <= 0 { return nil @@ -97,6 +110,9 @@ func cpuWatts(before, after map[string]counter, elapsed time.Duration) *float64 } delta += a.max } + if float64(delta)/1e6/elapsed.Seconds() > MaxPackageWatts { + return nil + } joules += float64(delta) / 1e6 read = true } @@ -156,6 +172,10 @@ func (m *Machine) gpuWatts(ctx context.Context) *float64 { if strings.Contains(filepath.Base(card), "-") || !m.exists(card+"/device/mem_info_vram_vendor") { continue } + // Asleep, it is not read: reading its sensors wakes it, as asking nvidia-smi would. + if status := m.read(card + "/device/power/runtime_status"); status != "" && status != "active" { + continue + } for _, hw := range m.glob(card + "/device/hwmon/hwmon*") { uw, ok := m.readInt(hw + "/power1_average") if !ok { @@ -217,9 +237,14 @@ func (m *Machine) Publish(line string) error { return os.Rename(tmp, path) } -// RunDraw samples and publishes until the context ends. A battery reading is instant, so it waits -// the interval itself; a counter reading waits inside its sample. +// RunDraw samples and publishes until the context ends. func RunDraw(ctx context.Context, m *Machine) error { + return runDraw(ctx, m, SampleEvery, m.Publish) +} + +// 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 { wait := func(d time.Duration) bool { select { case <-ctx.Done(): @@ -229,14 +254,14 @@ func RunDraw(ctx context.Context, m *Machine) error { } } for ctx.Err() == nil { - d := m.Sample(ctx, SampleEvery, wait) + d := m.Sample(ctx, every, wait) if ctx.Err() != nil { break } - if err := m.Publish(d.Line()); err != nil { + if err := publish(d.Line()); err != nil { return fmt.Errorf("publishing the draw: %w", err) } - if d.Machine != nil && !wait(SampleEvery) { + if d.Machine != nil && !wait(every) { break } } diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go index fa503e01..15752271 100644 --- a/modules/power/cmd/power/draw_test.go +++ b/modules/power/cmd/power/draw_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -61,8 +62,10 @@ func TestOnMainsTheProcessorIsMeasuredAndCalledTheProcessor(t *testing.T) { "/sys/class/drm/card1/device/hwmon/hwmon5/power1_average": "19000000", }) counter := filepath.Join(m.Root, "/sys/class/powercap/intel-rapl:0/energy_uj") + // 1 J over 50 ms: 20 W, well inside what a package draws. d := m.Sample(context.Background(), time.Second, func(time.Duration) bool { - return os.WriteFile(counter, []byte("11000000\n"), 0o644) == nil + time.Sleep(50 * time.Millisecond) + return os.WriteFile(counter, []byte("2000000\n"), 0o644) == nil }) if d.Machine != nil || d.CPU == nil || d.GPU != nil || *d.CPU <= 0 { t.Fatalf("%+v", d) @@ -135,3 +138,102 @@ func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) { t.Fatalf("%q", b) } } + +func TestAPackageSeenTwiceIsCountedOnce(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/powercap/intel-rapl:0/name": "package-0", + "/sys/class/powercap/intel-rapl:0/energy_uj": "100", + "/sys/class/powercap/intel-rapl-mmio:0/name": "package-0", + "/sys/class/powercap/intel-rapl-mmio:0/energy_uj": "999", + "/sys/class/powercap/intel-rapl:1/name": "package-1", + "/sys/class/powercap/intel-rapl:1/energy_uj": "5", + }) + got := m.packageCounters() + if len(got) != 2 || got["package-0"].uj != 100 || got["package-1"].uj != 5 { + t.Fatalf("%+v", got) + } +} + +func TestACounterResetGivesNoReading(t *testing.T) { + // After a resume the counter starts again near nothing: across its range that would be thousands + // of watts, which is no reading at all. + before := map[string]counter{"package-0": {uj: 40_000_000_000, max: 65_532_610_987}} + after := map[string]counter{"package-0": {uj: 1_000_000, max: 65_532_610_987}} + if w := cpuWatts(before, after, 5*time.Second); w != nil { + t.Fatalf("a reset counter read as %.0f W", *w) + } + // A true wrap, near the end of the range, is still counted. + before = map[string]counter{"package-0": {uj: 65_532_610_987 - 10_000_000, max: 65_532_610_987}} + after = map[string]counter{"package-0": {uj: 40_000_000, max: 65_532_610_987}} + if w := cpuWatts(before, after, 5*time.Second); w == nil || *w != 10 { + t.Fatalf("a wrapped counter read as %v", w) + } +} + +func TestASleepingAMDCardIsNotRead(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/drm/card0/device/mem_info_vram_vendor": "samsung", + "/sys/class/drm/card0/device/power/runtime_status": "suspended", + "/sys/class/drm/card0/device/hwmon/hwmon3/power1_average": "30000000", + }) + if w := m.gpuWatts(context.Background()); w != nil { + t.Fatalf("a suspended card was read: %v", *w) + } +} + +func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) { + m := tree(t, map[string]string{ + "/sys/class/power_supply/BAT0/type": "Battery", + "/sys/class/power_supply/BAT0/status": "Discharging", + "/sys/class/power_supply/BAT0/power_now": "12000000", + }) + ctx, cancel := context.WithCancel(context.Background()) + 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 }) + }() + time.Sleep(210 * time.Millisecond) + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("the loop did not stop when cancelled") + } + // One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall. + if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" { + 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) + } +} diff --git a/modules/power/files/bin/power-draw b/modules/power/files/bin/power-draw index 58430300..6d565083 100755 --- a/modules/power/files/bin/power-draw +++ b/modules/power/files/bin/power-draw @@ -4,7 +4,7 @@ # 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=/run/mesh-power/draw +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 From 76b63744e604fe1ff9870d8800e5d0dd85f8a193 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 15:22:58 +0200 Subject: [PATCH 3/6] 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. --- checks/paths/go.mod | 3 + checks/paths/main.go | 134 ++++++++++++++++++ checks/paths/main_test.go | 63 ++++++++ merge-check.sh | 3 + modules/i3status-rust/README.md | 8 ++ .../cmd/i3status-rust-tools/blocks_test.go | 24 +++- .../cmd/i3status-rust-tools/main.go | 2 + .../cmd/i3status-rust-tools/manifest_test.go | 1 + .../cmd/i3status-rust-tools/values.go | 118 +++++++++++++++ .../cmd/i3status-rust-tools/values_test.go | 92 ++++++++++++ .../i3status-rust/files/bin/i3status-value | 13 ++ modules/i3status-rust/files/block.tmpl | 8 +- modules/i3status-rust/module.json | 10 +- modules/power/README.md | 38 +++-- modules/power/cmd/power/draw.go | 46 +++++- modules/power/cmd/power/draw_test.go | 39 +---- modules/power/cmd/power/main.go | 2 + modules/power/cmd/power/publish.go | 76 ++++++++++ modules/power/cmd/power/publish_test.go | 61 ++++++++ modules/power/files/bin/power-draw | 12 -- modules/power/module.json | 17 ++- 21 files changed, 697 insertions(+), 73 deletions(-) create mode 100644 checks/paths/go.mod create mode 100644 checks/paths/main.go create mode 100644 checks/paths/main_test.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/values.go create mode 100644 modules/i3status-rust/cmd/i3status-rust-tools/values_test.go create mode 100755 modules/i3status-rust/files/bin/i3status-value create mode 100644 modules/power/cmd/power/publish.go create mode 100644 modules/power/cmd/power/publish_test.go delete mode 100755 modules/power/files/bin/power-draw diff --git a/checks/paths/go.mod b/checks/paths/go.mod new file mode 100644 index 00000000..bb495055 --- /dev/null +++ b/checks/paths/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/paths + +go 1.22 diff --git a/checks/paths/main.go b/checks/paths/main.go new file mode 100644 index 00000000..e0951a16 --- /dev/null +++ b/checks/paths/main.go @@ -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 . +// +// 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") +} diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go new file mode 100644 index 00000000..6c24540e --- /dev/null +++ b/checks/paths/main_test.go @@ -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) + } +} diff --git a/merge-check.sh b/merge-check.sh index 00d094d7..8a6eff54 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -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; } diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index ad706a85..49ec55d3 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -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: diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go index 83fc876b..8314ce40 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/blocks_test.go @@ -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) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/main.go b/modules/i3status-rust/cmd/i3status-rust-tools/main.go index 90918c19..ca8242f6 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/main.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/main.go @@ -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) diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index a39b1d72..ec41d8db 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -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) } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go new file mode 100644 index 00000000..8ea8e2a7 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -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 `. +// 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) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go new file mode 100644 index 00000000..e7ab28d2 --- /dev/null +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -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) + } +} diff --git a/modules/i3status-rust/files/bin/i3status-value b/modules/i3status-rust/files/bin/i3status-value new file mode 100755 index 00000000..8fea95e4 --- /dev/null +++ b/modules/i3status-rust/files/bin/i3status-value @@ -0,0 +1,13 @@ +#!/bin/sh +# i3status-value (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 diff --git a/modules/i3status-rust/files/block.tmpl b/modules/i3status-rust/files/block.tmpl index befa9c22..527a6426 100644 --- a/modules/i3status-rust/files/block.tmpl +++ b/modules/i3status-rust/files/block.tmpl @@ -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}} diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index d6a5706f..373338f4 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -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 (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": { diff --git a/modules/power/README.md b/modules/power/README.md index 3973605b..22a01b2e 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -13,7 +13,7 @@ states on the bus. | `/etc/mesh-power/moments/` | for each moment, every module's code as the controller placed it (`${shell::}`) | | `/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 diff --git a/modules/power/cmd/power/draw.go b/modules/power/cmd/power/draw.go index fa7206bd..eae75998 100644 --- a/modules/power/cmd/power/draw.go +++ b/modules/power/cmd/power/draw.go @@ -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) { diff --git a/modules/power/cmd/power/draw_test.go b/modules/power/cmd/power/draw_test.go index 15752271..6b06a449 100644 --- a/modules/power/cmd/power/draw_test.go +++ b/modules/power/cmd/power/draw_test.go @@ -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) - } -} diff --git a/modules/power/cmd/power/main.go b/modules/power/cmd/power/main.go index ac1d55d0..972a39fa 100644 --- a/modules/power/cmd/power/main.go +++ b/modules/power/cmd/power/main.go @@ -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) diff --git a/modules/power/cmd/power/publish.go b/modules/power/cmd/power/publish.go new file mode 100644 index 00000000..601510ed --- /dev/null +++ b/modules/power/cmd/power/publish.go @@ -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 + } +} diff --git a/modules/power/cmd/power/publish_test.go b/modules/power/cmd/power/publish_test.go new file mode 100644 index 00000000..a162aa68 --- /dev/null +++ b/modules/power/cmd/power/publish_test.go @@ -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") + } +} diff --git a/modules/power/files/bin/power-draw b/modules/power/files/bin/power-draw deleted file mode 100755 index 6d565083..00000000 --- a/modules/power/files/bin/power-draw +++ /dev/null @@ -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 diff --git a/modules/power/module.json b/modules/power/module.json index 794c857b..faa32384 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -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" } } } From 4f6cbd2197e89e00da6e0970f235a8498b737e68 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 15:54:15 +0200 Subject: [PATCH 4/6] Follow bar values so a dead watch speaks, date them by their reading, and widen the path check (hq ADR 0260) A watch that died left the block silent, a removed block kept its value, and a value frozen at its source looked fresh because its file was new. The follower now rereads a silent value and says so, drops removed blocks, and dates each file by the reading's own time. The path check takes /etc and /usr/local/lib in, ignores what a container sees inside itself, and lists the five findings standing today, which may only go. --- checks/paths/known | 8 + checks/paths/main.go | 145 +++++++++++-- checks/paths/main_test.go | 46 ++++ checks/paths/shared | 3 + merge-check.sh | 5 +- modules/i3status-rust/README.md | 9 +- .../cmd/i3status-rust-tools/values.go | 205 ++++++++++++++---- .../cmd/i3status-rust-tools/values_test.go | 139 +++++++++--- modules/power/README.md | 3 +- modules/power/module.json | 3 +- 10 files changed, 465 insertions(+), 101 deletions(-) create mode 100644 checks/paths/known create mode 100644 checks/paths/shared diff --git a/checks/paths/known b/checks/paths/known new file mode 100644 index 00000000..a6c036da --- /dev/null +++ b/checks/paths/known @@ -0,0 +1,8 @@ +# Findings standing when the check took /etc and /usr/local/lib in (2026-10-08). Each is a module reading +# another's file instead of asking over the bus. The list only shrinks: a finding that no longer occurs +# must leave it. Format: # why it is still here +asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks +asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same +asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same +asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/cmd/zephyrus/keys.go # its keys tool reads triggerhappy's triggers; should ask triggerhappy's tool +asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/module.json # comments naming triggerhappy's file, in files it writes diff --git a/checks/paths/main.go b/checks/paths/main.go index e0951a16..579fd0c1 100644 --- a/checks/paths/main.go +++ b/checks/paths/main.go @@ -1,11 +1,23 @@ // Command paths holds the catalogue's modules to talking over the bus (novox/hq ADR 0260): no module -// names a path under /run or /var/lib that another module's manifest places. Such a path is that -// module's state on its machine, and a second module reading or writing it is two modules talking -// through a file, which the bus exists to replace. +// names a path that another module's manifest places under /run, /var/lib, /usr/local/lib or /etc. +// Such a path is that module's state or configuration on its machine, and a second module reading or +// writing it is two modules talking through a file, which the bus exists to replace. // -// A module owns every `path` of its resources under /run/ or /var/lib/. Every other module's manifest -// and every file of its own except its documentation and its tests is searched for each, as a whole -// path: followed by nothing, a slash, or a character no path takes. +// A module owns every `path` of its resources under those roots. Every other module's manifest, and +// every file of its own but its documentation, its tests and its binaries, is searched for each as a +// whole path: followed by nothing, a slash, or a character no path takes. A path a container sees +// inside itself (the target of a `volumes` entry) is the container's, and is not searched. +// +// Two lists beside this file narrow it, each line a path or a finding and its reason after a `#`: +// +// - shared: paths every program on a machine reads as the machine's, not as a message from their +// owner — /etc/hosts, which every resolver reads. +// - known: the findings standing on the day the roots were widened (2026-10-08). The check fails on a +// finding not listed, and on a listed one that no longer occurs, so the list only shrinks. +// +// **What it cannot see:** a path built in code from pieces (`filepath.Join("/etc", name)`), one read +// from the environment or a setting, and one reached through a shell the module runs. It reads text, +// not what a program does; review and the runtime's grants cover the rest. // // go run . // @@ -13,6 +25,8 @@ package main import ( + "bufio" + "bytes" "encoding/json" "fmt" "io/fs" @@ -23,13 +37,8 @@ import ( "strings" ) -// stateRoots are where a machine keeps a module's state. -var stateRoots = []string{"/run/", "/var/lib/"} - -type manifest struct { - Module string `json:"module"` - Resources []map[string]any `json:"resources"` -} +// stateRoots are where a machine keeps a module's state and configuration. +var stateRoots = []string{"/run/", "/var/lib/", "/usr/local/lib/", "/etc/"} // Finding is one module naming another's path. type Finding struct { @@ -41,20 +50,50 @@ func (f Finding) String() string { f.Module, f.Path, f.File, f.Owner) } -// Check is every finding in the catalogue at root. +// Key is how a finding is listed in `known`: the module, the path and the file. +func (f Finding) Key() string { return f.Module + " " + f.Path + " " + f.File } + +// containerTargets drops what a container sees inside itself from a manifest before it is searched: +// the target half of every `volumes` entry of a container resource. +func containerTargets(m map[string]any) { + resources, _ := m["resources"].([]any) + for _, raw := range resources { + r, ok := raw.(map[string]any) + if !ok || r["type"] != "container" { + continue + } + volumes, _ := r["volumes"].([]any) + for i, v := range volumes { + if s, ok := v.(string); ok { + if src, _, two := strings.Cut(s, ":"); two { + volumes[i] = src + } + } + if o, ok := v.(map[string]any); ok { + delete(o, "target") + delete(o, "container") + } + } + } +} + +// Check is every finding in the catalogue at root, before the lists narrow it. func Check(root string) ([]Finding, error) { - dirs, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) if err != nil { return nil, err } owner := map[string]string{} modules := map[string]string{} - for _, f := range dirs { + for _, f := range files { raw, err := os.ReadFile(f) if err != nil { return nil, err } - var m manifest + var m struct { + Module string `json:"module"` + Resources []map[string]any `json:"resources"` + } if err := json.Unmarshal(raw, &m); err != nil { return nil, fmt.Errorf("%s: %w", f, err) } @@ -77,15 +116,14 @@ func Check(root string) ([]Finding, error) { for _, p := range paths { patterns[p] = regexp.MustCompile(regexp.QuoteMeta(p) + `(?:$|[^A-Za-z0-9._-])`) } - var out []Finding names := make([]string, 0, len(modules)) for n := range modules { names = append(names, n) } sort.Strings(names) + var out []Finding for _, module := range names { - dir := modules[module] - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(modules[module], func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } @@ -98,6 +136,16 @@ func Check(root string) ([]Finding, error) { if err != nil { return err } + if bytes.IndexByte(raw, 0) >= 0 { + return nil // a binary: what it says is in its source + } + if name == "module.json" { + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + containerTargets(m) + raw, _ = json.Marshal(m) + } + } text := string(raw) for _, p := range paths { if owner[p] != module && patterns[p].MatchString(text) { @@ -114,6 +162,46 @@ func Check(root string) ([]Finding, error) { return out, nil } +// list reads one of the lists beside this check: each line's text before a `#`, trimmed. +func list(path string) (map[string]bool, error) { + out := map[string]bool{} + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line, _, _ := strings.Cut(s.Text(), "#") + if line = strings.TrimSpace(line); line != "" { + out[line] = true + } + } + return out, s.Err() +} + +// Judge narrows the findings by the lists: what is left to refuse, and the known findings that no +// longer occur, which must leave the list. +func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, gone []string) { + seen := map[string]bool{} + for _, f := range found { + if shared[f.Path] { + continue + } + seen[f.Key()] = true + if !known[f.Key()] { + refused = append(refused, f) + } + } + for k := range known { + if !seen[k] { + gone = append(gone, k) + } + } + sort.Strings(gone) + return refused, gone +} + func main() { root := "." if len(os.Args) > 1 { @@ -124,11 +212,22 @@ func main() { fmt.Fprintln(os.Stderr, err) os.Exit(2) } - for _, f := range found { + here := filepath.Join(root, "checks", "paths") + shared, err1 := list(filepath.Join(here, "shared")) + known, err2 := list(filepath.Join(here, "known")) + if err1 != nil || err2 != nil { + fmt.Fprintln(os.Stderr, "the lists beside the check cannot be read:", err1, err2) + os.Exit(2) + } + refused, gone := Judge(found, shared, known) + for _, f := range refused { fmt.Println(f) } - if len(found) > 0 { + for _, k := range gone { + fmt.Printf("%q is in checks/paths/known and no longer occurs: take it out, so it cannot come back\n", k) + } + if len(refused) > 0 || len(gone) > 0 { os.Exit(1) } - fmt.Println("paths: no module names another's state on its machine") + fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known)) } diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go index 6c24540e..c70c5314 100644 --- a/checks/paths/main_test.go +++ b/checks/paths/main_test.go @@ -61,3 +61,49 @@ func TestACatalogueWhereEachKeepsToItsOwnIsClean(t *testing.T) { t.Fatalf("%v %v", found, err) } } + +func TestEtcAndUsrLocalLibAreAModulesTooButNotInsideAContainer(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/power/module.json": `{"module":"power","resources":[{"id":"m","type":"directory","path":"/etc/mesh-power/moments"},` + + `{"id":"s","type":"archive","path":"/usr/local/lib/mesh-power"}]}`, + "modules/reader/module.json": `{"module":"reader"}`, + "modules/reader/cmd/r/r.go": `const a, b = "/etc/mesh-power/moments/after-wake", "/usr/local/lib/mesh-power/bin/x"`, + // A container that mounts something at that path inside itself names its own world. + "modules/boxed/module.json": `{"module":"boxed","resources":[{"id":"c","type":"container",` + + `"volumes":["/srv/boxed:/etc/mesh-power/moments",{"source":"/srv/b","target":"/usr/local/lib/mesh-power"}]}]}`, + // A binary says what its source says. + "modules/boxed/bin/tool": "\x00\x01/etc/mesh-power/moments\x00", + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + var got []string + for _, f := range found { + got = append(got, f.Module+" "+f.Path) + } + want := []string{"reader /etc/mesh-power/moments", "reader /usr/local/lib/mesh-power"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("found %v, want %v", got, want) + } +} + +func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) { + found := []Finding{ + {Module: "dns", File: "modules/dns/module.json", Path: "/etc/hosts", Owner: "hostname"}, + {Module: "laptop", File: "modules/laptop/k.go", Path: "/etc/triggers", Owner: "keys"}, + {Module: "new", File: "modules/new/n.go", Path: "/run/mesh-power", Owner: "power"}, + } + shared := map[string]bool{"/etc/hosts": true} + known := map[string]bool{ + "laptop /etc/triggers modules/laptop/k.go": true, + "old /run/x modules/old/o.go": true, + } + refused, gone := Judge(found, shared, known) + if len(refused) != 1 || refused[0].Module != "new" { + t.Fatalf("refused %v", refused) + } + if len(gone) != 1 || gone[0] != "old /run/x modules/old/o.go" { + t.Fatalf("gone %v", gone) + } +} diff --git a/checks/paths/shared b/checks/paths/shared new file mode 100644 index 00000000..e305bcff --- /dev/null +++ b/checks/paths/shared @@ -0,0 +1,3 @@ +# Paths every program on a machine reads as the machine's own, not as a message from the module that +# writes them (checks/paths, novox/hq ADR 0260). One per line, with why. +/etc/hosts # every resolver on the machine reads it; the hostname module writes it for all of them diff --git a/merge-check.sh b/merge-check.sh index 8a6eff54..edec11d8 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -19,8 +19,9 @@ # retired-words, in any text a module's tools can show an agent — every string in its own code that is # not a test, and every description in its manifest. Run over every module, not only the touched ones, # so a word newly retired is found everywhere it stands. The check's own test runs first. -# 4. the paths (novox/hq ADR 0260): no module names a path under /run or /var/lib that another module's -# manifest places — modules talk over the bus, not through each other's files. Over every module. +# 4. the paths (novox/hq ADR 0260): no module names a path under /run, /var/lib, /usr/local/lib or /etc +# that another module's manifest places — modules talk over the bus, not through each other's files. +# Over every module; checks/paths/shared and checks/paths/known narrow it, and known only shrinks. set -eu (cd checks/words && go test -count=1 . && go run . ../..) diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 49ec55d3..19721602 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -44,9 +44,12 @@ contributes it to `node-bar` as data that says what it shows, and this module re - **A value from the bus:** a block that `shows: state` names another module's state, such as `power.draw`. The mesh grants this module the read, because a module on its machine offers it; the manifest names no other module. This module's bundle watches it and leaves the value's text in the - account's runtime directory (`i3status-rust/values/`), and the block runs `~/.local/bin/i3status-value`, - which prints it, or nothing when it is more than 30 s old, so the block hides. No file of another - module is read: modules talk over the bus. + account's runtime directory (`i3status-rust/values/`), dated with the value's own `at`, and the block + runs `~/.local/bin/i3status-value`, which prints it, or nothing when it was measured more than 30 s + ago, so the block hides a value frozen at its source. A watch that says nothing for a minute is read + again and said in the log, a refused watch is said and tried again, and a value no bar shows any more + is no longer kept. The bus grants this module the one key each block shows (the machine's own unless + the block names another, `[a-z0-9-]`). No file of another module is read: modules talk over the bus. - **The battery** is i3status-rust's own battery block, which reads the kernel's power-supply class itself: the bar reading the machine, not another module. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go index 8ea8e2a7..b0ddc30c 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strconv" + "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" @@ -17,12 +18,22 @@ import ( // // A block that `shows` a state is rendered as a custom block running `i3status-value `. // i3status-rs cannot reach the bus, and this bundle can: the node's runtime launched it, and the mesh -// granted this module a read of every state the modules on its machine offer the bar. So it watches each -// state its bars name and leaves the value's text in this module's own directory, where the block's -// command reads it. No file of another module is read, and nothing but the bus passes between them. +// granted this module a read of the one key each such block shows. So it follows each state its bars +// name and leaves the value's text in this module's own directory, where the block's command reads it. +// No file of another module is read, and nothing but the bus passes between them. +// +// **The value's age is its own.** The file is dated with the value's `at` when it has one, so the +// block hides a value that stopped changing at its source, not only one that stopped arriving here. -// valueCall is how a bar names a value: the command the template renders. -var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([^\s"]+)`) +// FollowEvery is how often the bars are read again and every followed value looked at; Silent is how +// long a watch may say nothing before the value is read again — twice the power draw's lifetime. +var ( + FollowEvery = 30 * time.Second + Silent = 60 * time.Second +) + +// valueCall is how a bar names a value: the command the template renders. A key is a machine's name. +var valueCall = regexp.MustCompile(`i3status-value ([a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*) ([a-z0-9][a-z0-9-]*)`) // Wanted is one state and key a bar shows. type Wanted struct{ State, Key string } @@ -54,10 +65,11 @@ func valuesDir() string { return filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "i3status-rust", "values") } -// keep writes one change of a value to its file: the value's `text`, or no file once it is deleted. -func keep(dir string, w Wanted, c stdio.StateChange) error { +// keep writes one value to its file: its `text`, dated with its `at` when it has one; no file once the +// value is deleted. +func keep(dir string, w Wanted, op string, value json.RawMessage) error { path := filepath.Join(dir, w.file()) - if c.Op == "delete" || c.Op == "purge" { + if op == "delete" || op == "purge" || value == nil { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } @@ -65,8 +77,9 @@ func keep(dir string, w Wanted, c stdio.StateChange) error { } var v struct { Text string `json:"text"` + At string `json:"at"` } - if err := json.Unmarshal(c.Value, &v); err != nil { + if err := json.Unmarshal(value, &v); err != nil { return fmt.Errorf("%s's value for %s has no text: %w", w.State, w.Key, err) } if err := os.MkdirAll(dir, 0o700); err != nil { @@ -76,43 +89,157 @@ func keep(dir string, w Wanted, c stdio.StateChange) error { if err := os.WriteFile(tmp, []byte(v.Text+"\n"), 0o600); err != nil { return err } + if at, err := time.Parse(time.RFC3339, v.At); err == nil { + if err := os.Chtimes(tmp, at, at); err != nil { + return err + } + } return os.Rename(tmp, path) } -// Watcher is a function that watches one key of one state; the SDK's in the bundle, a fake in a test. -type Watcher func(state, key string, handle func(stdio.StateChange) error) error - -// followValues watches every value the bars name, starting the ones not yet watched each time it is -// called; one the runtime refuses — a read not granted yet — is tried again the next time. -func followValues(files []string, dir string, watching map[Wanted]bool, watch Watcher) []string { - var said []string - for _, w := range wantedIn(files) { - if watching[w] { - continue - } - w := w - err := watch(w.State, w.Key, func(c stdio.StateChange) error { return keep(dir, w, c) }) - if err != nil { - said = append(said, fmt.Sprintf("cannot watch %s %s: %v", w.State, w.Key, err)) - continue - } - watching[w] = true - } - return said +// Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the +// bundle, a fake in a test. +type Bus struct { + Watch func(state, key string, handle func(op string, value json.RawMessage) error) error + Get func(state, key string) (json.RawMessage, bool, error) } -// FollowValues runs followValues every half minute for as long as the bundle runs, so a block a push -// adds is followed without a restart. -func FollowValues() { - watching := map[Wanted]bool{} - watch := func(state, key string, handle func(stdio.StateChange) error) error { - return stdio.State(state).Watch(key, handle) +// following is one value followed: when its watch last said anything, and whether a bar still shows it. +type following struct { + last time.Time + stopped bool +} + +// Follower follows the values the bars name. +type Follower struct { + Dir string + Bus Bus + Now func() time.Time + Say func(string) + + mu sync.Mutex + of map[Wanted]*following +} + +// Round reads the bars once and brings what is followed into line with them: +// - a value a bar names and nothing follows is watched; a watch the runtime refuses — a read not +// granted yet — is said and tried again next round; +// - a value whose watch has said nothing for Silent is read again, and said once, so a dead watch is +// never silent; +// - a value no bar names any more is no longer kept: its file goes, and what its watch hands over is +// dropped. The SDK has no way to end a watch; one that ends with the bundle is the price. +func (f *Follower) Round(files []string) { + f.mu.Lock() + if f.of == nil { + f.of = map[Wanted]*following{} } + f.mu.Unlock() + wanted := map[Wanted]bool{} + for _, w := range wantedIn(files) { + wanted[w] = true + f.mu.Lock() + fw, known := f.of[w] + f.mu.Unlock() + switch { + case !known: + f.start(w) + case fw.stopped: + f.mu.Lock() + fw.stopped = false + fw.last = f.Now() + f.mu.Unlock() + f.reread(w) + case f.Now().Sub(f.lastOf(w)) > Silent: + f.Say(fmt.Sprintf("the watch of %s %s has said nothing for %s; reading it again", w.State, w.Key, Silent)) + f.reread(w) + } + } + f.mu.Lock() + var gone []Wanted + for w, fw := range f.of { + if !wanted[w] && !fw.stopped { + fw.stopped = true + gone = append(gone, w) + } + } + f.mu.Unlock() + for _, w := range gone { + if err := keep(f.Dir, w, "delete", nil); err != nil { + f.Say(err.Error()) + } + f.Say(fmt.Sprintf("no bar shows %s %s any more; it is no longer kept", w.State, w.Key)) + } +} + +func (f *Follower) lastOf(w Wanted) time.Time { + f.mu.Lock() + defer f.mu.Unlock() + return f.of[w].last +} + +func (f *Follower) start(w Wanted) { + err := f.Bus.Watch(w.State, w.Key, func(op string, value json.RawMessage) error { + f.mu.Lock() + fw := f.of[w] + if fw != nil { + fw.last = f.Now() + } + stopped := fw == nil || fw.stopped + f.mu.Unlock() + if stopped { + return nil + } + return keep(f.Dir, w, op, value) + }) + if err != nil { + f.Say(fmt.Sprintf("cannot watch %s %s: %v; trying again in %s", w.State, w.Key, err, FollowEvery)) + return + } + f.mu.Lock() + if fw := f.of[w]; fw == nil { + f.of[w] = &following{last: f.Now()} + } + f.mu.Unlock() +} + +func (f *Follower) reread(w Wanted) { + value, found, err := f.Bus.Get(w.State, w.Key) + f.mu.Lock() + f.of[w].last = f.Now() + f.mu.Unlock() + if err != nil { + f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err)) + return + } + op := "put" + if !found { + op = "delete" + } + if err := keep(f.Dir, w, op, value); err != nil { + f.Say(err.Error()) + } +} + +// FollowValues runs a Round every FollowEvery for as long as the bundle runs, so a block a push adds +// is followed without a restart. +func FollowValues() { + f := &Follower{Dir: valuesDir(), Now: time.Now, + Say: func(s string) { fmt.Fprintln(os.Stderr, "i3status-rust:", s) }, + Bus: Bus{ + Watch: func(state, key string, handle func(string, json.RawMessage) error) error { + return stdio.State(state).Watch(key, func(c stdio.StateChange) error { return handle(c.Op, c.Value) }) + }, + Get: func(state, key string) (json.RawMessage, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }, + }} for { files, _ := filepath.Glob(filepath.Join(configDir(), "*.toml")) - for _, s := range followValues(files, valuesDir(), watching, watch) { - fmt.Fprintln(os.Stderr, "i3status-rust:", s) - } - time.Sleep(30 * time.Second) + f.Round(files) + time.Sleep(FollowEvery) } } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go index e7ab28d2..b7e7a633 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "os" "os/exec" @@ -8,56 +9,128 @@ import ( "strings" "testing" "time" - - stdio "git.novox.be/novox/mesh-sdk/go" ) // Defends novox/hq ADR 0260: a value another module keeps reaches the bar over the bus, through this -// module's own bundle and directory, and an old one is not shown as if it were now. +// module's own bundle and directory; a dead watch is never silent; a removed block is no longer kept; +// and a value frozen at its source hides. -func TestAValueIsKeptAsItsTextAndGoneWhenDeleted(t *testing.T) { +func TestAValueIsKeptAsItsTextDatedByItsAtAndGoneWhenDeleted(t *testing.T) { dir := t.TempDir() w := Wanted{"power.draw", "laptop"} - if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`{"text":"18.2 W machine","watts":18.2}`)}); err != nil { + at := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + if err := keep(dir, w, "put", json.RawMessage(`{"text":"18.2 W machine","at":"2026-10-08T12:00:00Z"}`)); err != nil { t.Fatal(err) } - b, err := os.ReadFile(filepath.Join(dir, "power.draw@laptop")) - if err != nil || string(b) != "18.2 W machine\n" { - t.Fatalf("%q %v", b, err) + path := filepath.Join(dir, "power.draw@laptop") + b, err := os.ReadFile(path) + info, _ := os.Stat(path) + if err != nil || string(b) != "18.2 W machine\n" || !info.ModTime().Equal(at) { + t.Fatalf("%q %v, dated %v", b, err, info.ModTime()) } - if err := keep(dir, w, stdio.StateChange{Op: "delete"}); err != nil { + if err := keep(dir, w, "delete", nil); err != nil { t.Fatal(err) } - if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("a deleted value is still shown") } - if err := keep(dir, w, stdio.StateChange{Op: "put", Value: []byte(`"no text"`)}); err == nil { + if err := keep(dir, w, "put", json.RawMessage(`"no text"`)); err == nil { t.Fatal("a value with no text was kept") } } -func TestARefusedWatchIsTriedAgainAndAWatchedOneIsNot(t *testing.T) { - bar := filepath.Join(t.TempDir(), "bottom-bar.toml") - if err := os.WriteFile(bar, []byte(`command = "~/.local/bin/i3status-value power.draw laptop"`+"\n"), 0o644); err != nil { +type fakeBus struct { + refuse bool + watched []Wanted + handles map[Wanted]func(string, json.RawMessage) error + gets int + value json.RawMessage +} + +func (b *fakeBus) bus() Bus { + return Bus{ + Watch: func(state, key string, h func(string, json.RawMessage) error) error { + if b.refuse { + return errors.New("not granted") + } + w := Wanted{state, key} + b.watched = append(b.watched, w) + if b.handles == nil { + b.handles = map[Wanted]func(string, json.RawMessage) error{} + } + b.handles[w] = h + return nil + }, + Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.value != nil, nil }, + } +} + +func barFiles(t *testing.T, content string) []string { + t.Helper() + f := filepath.Join(t.TempDir(), "bottom-bar.toml") + if err := os.WriteFile(f, []byte(content), 0o644); err != nil { t.Fatal(err) } - watching := map[Wanted]bool{} - calls := 0 - refuse := func(string, string, func(stdio.StateChange) error) error { calls++; return errors.New("not granted") } - if said := followValues([]string{bar}, t.TempDir(), watching, refuse); len(said) != 1 || !strings.Contains(said[0], "not granted") { + return []string{f} +} + +func TestAFollowerSaysARefusedWatchRereadsASilentOneAndDropsARemovedBlock(t *testing.T) { + now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC) + var said []string + b := &fakeBus{refuse: true} + dir := t.TempDir() + f := &Follower{Dir: dir, Bus: b.bus(), Now: func() time.Time { return now }, Say: func(s string) { said = append(said, s) }} + bar := barFiles(t, `command = "~/.local/bin/i3status-value power.draw laptop"`) + + // Refused: said, and tried again the next round. + f.Round(bar) + if len(said) != 1 || !strings.Contains(said[0], "cannot watch power.draw laptop: not granted") { t.Fatalf("%v", said) } - accept := func(state, key string, _ func(stdio.StateChange) error) error { - calls++ - if state != "power.draw" || key != "laptop" { - t.Fatalf("watched %s %s", state, key) - } - return nil + b.refuse = false + f.Round(bar) + if len(b.watched) != 1 { + t.Fatalf("watched %v", b.watched) } - followValues([]string{bar}, t.TempDir(), watching, accept) - followValues([]string{bar}, t.TempDir(), watching, accept) - if calls != 2 { - t.Fatalf("%d watches; once refused, once taken, then left alone", calls) + + // Silent for longer than Silent: said, and read again. + b.value = json.RawMessage(`{"text":"9.0 W CPU","at":"2026-10-08T12:01:00Z"}`) + now = now.Add(Silent + time.Second) + f.Round(bar) + if b.gets != 1 || !strings.Contains(said[len(said)-1], "has said nothing") { + t.Fatalf("gets %d, said %v", b.gets, said) + } + if got, _ := os.ReadFile(filepath.Join(dir, "power.draw@laptop")); string(got) != "9.0 W CPU\n" { + t.Fatalf("%q", got) + } + // Not again while the watch speaks. + if err := b.handles[Wanted{"power.draw", "laptop"}]("put", b.value); err != nil { + t.Fatal(err) + } + f.Round(bar) + if b.gets != 1 { + t.Fatalf("read again while the watch speaks: %d", b.gets) + } + + // Its block removed: the file goes, and what the watch still hands over is dropped. + f.Round(barFiles(t, "no values here")) + if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + t.Fatal("a value no bar shows is still kept") + } + if err := b.handles[Wanted{"power.draw", "laptop"}]("put", b.value); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + t.Fatal("a stopped watch still writes") + } + if !strings.Contains(said[len(said)-1], "no longer kept") { + t.Fatalf("%v", said) + } +} + +func TestAKeyIsAMachinesName(t *testing.T) { + if w := wantedIn(barFiles(t, `i3status-value power.draw ../etc`+"\n"+`i3status-value power.draw Laptop`)); len(w) != 0 { + t.Fatalf("%v", w) } } @@ -76,17 +149,19 @@ func TestTheBlocksCommandShowsOnlyAFreshValue(t *testing.T) { if got := run(); got != "" { t.Fatalf("no value: %q", got) } - if err := keep(dir, Wanted{"power.draw", "laptop"}, stdio.StateChange{Op: "put", Value: []byte(`{"text":"8.0 W CPU"}`)}); err != nil { + fresh := time.Now().UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+fresh+`"}`)); err != nil { t.Fatal(err) } if got := run(); got != "8.0 W CPU" { t.Fatalf("fresh: %q", got) } - old := time.Now().Add(-31 * time.Second) - if err := os.Chtimes(filepath.Join(dir, "power.draw@laptop"), old, old); err != nil { + // A value measured 31 s ago hides, however recently it arrived. + old := time.Now().Add(-31 * time.Second).UTC().Format(time.RFC3339) + if err := keep(dir, Wanted{"power.draw", "laptop"}, "put", json.RawMessage(`{"text":"8.0 W CPU","at":"`+old+`"}`)); err != nil { t.Fatal(err) } if got := run(); got != "" { - t.Fatalf("31 s old: %q", got) + t.Fatalf("measured 31 s ago: %q", got) } } diff --git a/modules/power/README.md b/modules/power/README.md index 22a01b2e..d1076525 100644 --- a/modules/power/README.md +++ b/modules/power/README.md @@ -58,7 +58,8 @@ permission is loosened. Two parts of this module carry the reading: 1. the process `mesh-power-draw` (`power draw`, as root) samples every 5 s and leaves the averaged rate in `/run/mesh-power/draw`, this module's own directory, read by nothing but this module; 2. the module's bundle, which the runtime launches, puts each fresh reading in its state `draw`, keyed by - the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`). + the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`). The + state is `per-machine`: each machine's instance is granted its own machine's key and no other. **State, not events, by bus load.** A reading is the current value and is replaced every five seconds; nobody needs the ones before. A key per machine keeps one value, so the bus holds at most one reading diff --git a/modules/power/module.json b/modules/power/module.json index faa32384..6411982c 100644 --- a/modules/power/module.json +++ b/modules/power/module.json @@ -23,7 +23,8 @@ "state": [ { "name": "draw", - "ttl-seconds": 30 + "ttl-seconds": 30, + "per-machine": true } ], "tools": [ From 0d54ecc7642b4c4b572dab2c40447fe150326a73 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:12:36 +0200 Subject: [PATCH 5/6] Keep the value a watch hands over at once, watch a dead watch again, and let the path lists grow only in the open (hq ADR 0260) The SDK hands the current value over before Watch returns, and the follower had no entry for it yet, so the first value was dropped. A silent watch is now told from an expired key and watched again rather than polled, each write takes its own temporary file, drop-in directories are no module's, and an entry added to the path check's lists is refused unless its line says why. --- checks/paths/known | 2 - checks/paths/main.go | 69 +++++-- checks/paths/main_test.go | 26 +++ merge-check.sh | 12 +- modules/i3status-rust/README.md | 7 +- .../cmd/i3status-rust-tools/values.go | 135 ++++++++---- .../cmd/i3status-rust-tools/values_test.go | 193 +++++++++++++----- 7 files changed, 329 insertions(+), 115 deletions(-) diff --git a/checks/paths/known b/checks/paths/known index a6c036da..31835293 100644 --- a/checks/paths/known +++ b/checks/paths/known @@ -4,5 +4,3 @@ asus-zephyrus-g14 /etc/mesh-power modules/asus-zephyrus-g14/cmd/zephyrus/check.go # its check reads the power module's moment file; should ask power_hooks asus-zephyrus-g14 /etc/mesh-power/moments modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same asus-zephyrus-g14 /etc/mesh-power/moments/after-wake modules/asus-zephyrus-g14/cmd/zephyrus/check.go # the same -asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/cmd/zephyrus/keys.go # its keys tool reads triggerhappy's triggers; should ask triggerhappy's tool -asus-zephyrus-g14 /etc/triggerhappy/triggers.d modules/asus-zephyrus-g14/module.json # comments naming triggerhappy's file, in files it writes diff --git a/checks/paths/main.go b/checks/paths/main.go index 579fd0c1..122b7aa2 100644 --- a/checks/paths/main.go +++ b/checks/paths/main.go @@ -8,6 +8,10 @@ // 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. // +// **A drop-in directory is no module's.** A directory whose name ends in `.d` (`/etc/sysctl.d`, +// `/etc/systemd/system/.d`, `/etc/X11/xorg.conf.d`) is a place several modules each put their own +// file, so naming it is not reaching into one module's state; the files in it are each their placer's. +// // 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 @@ -15,6 +19,10 @@ // - 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. // +// **Neither list may grow unseen.** With `-base ` holding the base branch's copies of both, a line +// the change adds is refused unless its reason says `allow:` and why — so an addition is a decision a +// reviewer reads in the diff, not a way past the check. +// // **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. @@ -100,6 +108,9 @@ func Check(root string) ([]Finding, error) { modules[m.Module] = filepath.Dir(f) for _, r := range m.Resources { p, _ := r["path"].(string) + if r["type"] == "directory" && strings.HasSuffix(filepath.Base(p), ".d") { + continue // a drop-in directory: several modules' place, no one module's + } for _, root := range stateRoots { if strings.HasPrefix(p, root) && len(p) > len(root) { owner[strings.TrimRight(p, "/")] = m.Module @@ -162,22 +173,37 @@ 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{} +// list reads one of the lists beside this check: each line's entry before a `#`, trimmed, with its +// reason after it. +func list(path string) (map[string]bool, map[string]string, error) { + out, why := map[string]bool{}, map[string]string{} f, err := os.Open(path) if err != nil { - return nil, err + return nil, nil, err } defer f.Close() s := bufio.NewScanner(f) for s.Scan() { - line, _, _ := strings.Cut(s.Text(), "#") + line, reason, _ := strings.Cut(s.Text(), "#") if line = strings.TrimSpace(line); line != "" { out[line] = true + why[line] = strings.TrimSpace(reason) } } - return out, s.Err() + return out, why, s.Err() +} + +// Grown is every entry a list has that its base copy does not, and whose reason does not say `allow:`. +func Grown(name string, now map[string]bool, why map[string]string, base map[string]bool) []string { + var out []string + for entry := range now { + if !base[entry] && !strings.Contains(why[entry], "allow:") { + out = append(out, fmt.Sprintf("checks/paths/%s gains %q, which the base branch's copy does not hold; "+ + "say why after `# allow:` on its line, or keep it out", name, entry)) + } + } + sort.Strings(out) + return out } // Judge narrows the findings by the lists: what is left to refuse, and the known findings that no @@ -203,9 +229,13 @@ func Judge(found []Finding, shared, known map[string]bool) (refused []Finding, g } func main() { - root := "." - if len(os.Args) > 1 { - root = os.Args[1] + root, baseDir := ".", "" + args := os.Args[1:] + if len(args) >= 2 && args[0] == "-base" { + baseDir, args = args[1], args[2:] + } + if len(args) > 0 { + root = args[0] } found, err := Check(root) if err != nil { @@ -213,20 +243,35 @@ func main() { os.Exit(2) } here := filepath.Join(root, "checks", "paths") - shared, err1 := list(filepath.Join(here, "shared")) - known, err2 := list(filepath.Join(here, "known")) + shared, sharedWhy, err1 := list(filepath.Join(here, "shared")) + known, knownWhy, 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) } + var grown []string + if baseDir == "" { + fmt.Println("NOT COMPARED: no base branch's lists were given, so additions to them were not judged") + } else { + baseShared, _, err1 := list(filepath.Join(baseDir, "shared")) + baseKnown, _, err2 := list(filepath.Join(baseDir, "known")) + if err1 != nil || err2 != nil { + fmt.Fprintln(os.Stderr, "the base branch's lists cannot be read:", err1, err2) + os.Exit(2) + } + grown = append(Grown("shared", shared, sharedWhy, baseShared), Grown("known", known, knownWhy, baseKnown)...) + } refused, gone := Judge(found, shared, known) + for _, g := range grown { + fmt.Println(g) + } for _, f := range refused { fmt.Println(f) } 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 { + if len(refused) > 0 || len(gone) > 0 || len(grown) > 0 { os.Exit(1) } fmt.Printf("paths: no module names another's state on its machine (%d known, listed to go)\n", len(known)) diff --git a/checks/paths/main_test.go b/checks/paths/main_test.go index c70c5314..cf656593 100644 --- a/checks/paths/main_test.go +++ b/checks/paths/main_test.go @@ -107,3 +107,29 @@ func TestSharedPathsPassAndKnownFindingsOnlyShrink(t *testing.T) { t.Fatalf("gone %v", gone) } } + +func TestADropInDirectoryIsNoModulesButItsFilesAre(t *testing.T) { + root := catalogue(t, map[string]string{ + "modules/memory/module.json": `{"module":"memory","resources":[{"id":"d","type":"directory","path":"/etc/sysctl.d"},` + + `{"id":"f","type":"file","path":"/etc/sysctl.d/90-memory.conf"}]}`, + "modules/other/module.json": `{"module":"other","resources":[{"id":"f","type":"file","path":"/etc/sysctl.d/80-other.conf"}]}`, + "modules/other/cmd/o/o.go": `const a, b = "/etc/sysctl.d", "/etc/sysctl.d/90-memory.conf"`, + }) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Path != "/etc/sysctl.d/90-memory.conf" || found[0].Module != "other" { + t.Fatalf("%v", found) + } +} + +func TestAListGrowsOnlyWithAReasonTheDiffShows(t *testing.T) { + base := map[string]bool{"/etc/hosts": true} + now := map[string]bool{"/etc/hosts": true, "/etc/a": true, "/etc/b": true} + why := map[string]string{"/etc/a": "because", "/etc/b": "allow: every program reads it"} + got := Grown("shared", now, why, base) + if len(got) != 1 || !strings.Contains(got[0], `"/etc/a"`) { + t.Fatalf("%v", got) + } +} diff --git a/merge-check.sh b/merge-check.sh index edec11d8..2b07c81f 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -25,7 +25,17 @@ set -eu (cd checks/words && go test -count=1 . && go run . ../..) -(cd checks/paths && go test -count=1 . && go run . ../..) +# The lists that narrow the paths check may not grow unseen: the base branch's copies are what they are +# judged against (MESH_CHECK_BASE, main when unset), read from the clone the check runs in. +base_lists="" +base_ref="origin/${MESH_CHECK_BASE:-main}" +if git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null 2>&1 && + git cat-file -e "$base_ref:checks/paths/known" 2>/dev/null && git cat-file -e "$base_ref:checks/paths/shared" 2>/dev/null; then + base_lists=$(mktemp -d) + git show "$base_ref:checks/paths/known" >"$base_lists/known" + git show "$base_ref:checks/paths/shared" >"$base_lists/shared" +fi +(cd checks/paths && go test -count=1 . && if [ -n "$base_lists" ]; then go run . -base "$base_lists" ../..; else go run . ../..; fi) if [ -n "${MESH_GATE:-}" ]; then checked=$("$MESH_GATE" module check modules/*/module.json) || { printf '%s\n' "$checked"; exit 1; } diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index 19721602..f8e94ce0 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -46,9 +46,10 @@ contributes it to `node-bar` as data that says what it shows, and this module re 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/`), 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 + ago, so the block hides a value frozen at its source. A watch that says nothing for a minute is + looked at once: a key that is gone has expired at its source and shows nothing; a key still there + means the watch died, so it is watched again and that is logged once. A refused watch is logged and + tried again, and a value no bar shows any more is no longer kept. The bus grants this module the one key each block shows (the machine's own unless the block names another, `[a-z0-9-]`). No file of another module is read: modules talk over the bus. - **The battery** is i3status-rust's own battery block, which reads the kernel's power-supply class itself: the bar reading the machine, not another module. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values.go b/modules/i3status-rust/cmd/i3status-rust-tools/values.go index b0ddc30c..3e38c2c8 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values.go @@ -66,7 +66,8 @@ func valuesDir() string { } // keep writes one value to its file: its `text`, dated with its `at` when it has one; no file once the -// value is deleted. +// value is deleted. Each write goes through a temporary file of its own and is renamed into place, so a +// watch's write and a reread's never meet halfway. func keep(dir string, w Wanted, op string, value json.RawMessage) error { path := filepath.Join(dir, w.file()) if op == "delete" || op == "purge" || value == nil { @@ -85,16 +86,24 @@ func keep(dir string, w Wanted, op string, value json.RawMessage) error { 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 { + tmp, err := os.CreateTemp(dir, "."+w.file()+".*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) // gone after the rename; left behind only on a failure + if _, err := tmp.WriteString(v.Text + "\n"); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { return err } if at, err := time.Parse(time.RFC3339, v.At); err == nil { - if err := os.Chtimes(tmp, at, at); err != nil { + if err := os.Chtimes(tmp.Name(), at, at); err != nil { return err } } - return os.Rename(tmp, path) + return os.Rename(tmp.Name(), path) } // Bus is what following needs of the bus: a watch of one key, and a read of it. The SDK's in the @@ -104,10 +113,19 @@ type Bus struct { Get func(state, key string) (json.RawMessage, bool, error) } -// following is one value followed: when its watch last said anything, and whether a bar still shows it. +// following is one value followed. type following struct { - last time.Time + // last is when its watch last handed anything over, or when it was last started or found expired. + last time.Time + // gen is which watch is the current one: a watch started again leaves the old one running (the SDK + // cannot end it), and what an old one hands over is dropped. + gen int + // stopped is a value no bar shows any more. stopped bool + // dead is a watch found silent while its value went on, said once until a watch speaks again. + dead bool + // expired is a value its source stopped giving: the key is gone and its watch has nothing to say. + expired bool } // Follower follows the values the bars name. @@ -124,8 +142,10 @@ type Follower struct { // 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 whose watch has said nothing for Silent is read once. A key that is gone has expired at +// its source (its lifetime ends without an event), and is left until the watch speaks or another +// Silent has passed. A key still there means the watch missed it: the value is kept, the watch is +// started again, and that is said once — not every round — until a watch speaks again; // - 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) { @@ -139,19 +159,22 @@ func (f *Follower) Round(files []string) { wanted[w] = true f.mu.Lock() fw, known := f.of[w] + var stopped, silent bool + if known { + stopped = fw.stopped + silent = f.Now().Sub(fw.last) > Silent + } f.mu.Unlock() switch { case !known: f.start(w) - case fw.stopped: + case 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.start(w) + case silent: + f.silent(w) } } f.mu.Lock() @@ -159,6 +182,7 @@ func (f *Follower) Round(files []string) { for w, fw := range f.of { if !wanted[w] && !fw.stopped { fw.stopped = true + fw.gen++ gone = append(gone, w) } } @@ -171,52 +195,77 @@ func (f *Follower) Round(files []string) { } } -func (f *Follower) lastOf(w Wanted) time.Time { - f.mu.Lock() - defer f.mu.Unlock() - return f.of[w].last -} - +// start watches a value, as a new generation. Its entry is there before the watch begins, because the +// SDK hands every current value over before Watch returns, and a value handed to nobody is lost. func (f *Follower) start(w Wanted) { + f.mu.Lock() + fw, existed := f.of[w] + if !existed { + fw = &following{} + f.of[w] = fw + } + fw.gen++ + gen := fw.gen + fw.last = f.Now() + f.mu.Unlock() 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() + current := f.of[w] == fw && fw.gen == gen && !fw.stopped + if current { + fw.last, fw.dead, fw.expired = f.Now(), false, false } - stopped := fw == nil || fw.stopped f.mu.Unlock() - if stopped { + if !current { return nil } return keep(f.Dir, w, op, value) }) if err != nil { + f.mu.Lock() + if !existed { + delete(f.of, w) + } else { + fw.gen++ // the refused watch is no generation; the next round tries again + } + f.mu.Unlock() 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) { +// silent looks once at a value whose watch has said nothing for Silent. +func (f *Follower) silent(w Wanted) { value, found, err := f.Bus.Get(w.State, w.Key) f.mu.Lock() - f.of[w].last = f.Now() + fw := f.of[w] + fw.last = f.Now() + wasDead, wasExpired := fw.dead, fw.expired f.mu.Unlock() - if err != nil { + switch { + case 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()) + case !found: + // Expired at its source: nothing to show, and nothing wrong with the watch. + if err := keep(f.Dir, w, "delete", nil); err != nil { + f.Say(err.Error()) + } + f.mu.Lock() + fw.expired = true + f.mu.Unlock() + if !wasExpired { + f.Say(fmt.Sprintf("%s %s has expired at its source; showing nothing until it is given again", w.State, w.Key)) + } + default: + if err := keep(f.Dir, w, "put", value); err != nil { + f.Say(err.Error()) + } + if !wasDead { + f.Say(fmt.Sprintf("the watch of %s %s said nothing for %s while its value went on; watching it again", + w.State, w.Key, Silent)) + } + f.mu.Lock() + fw.dead = true + f.mu.Unlock() + f.start(w) } } diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go index b7e7a633..8a47e1fa 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/values_test.go @@ -40,11 +40,13 @@ func TestAValueIsKeptAsItsTextDatedByItsAtAndGoneWhenDeleted(t *testing.T) { } type fakeBus struct { - refuse bool - watched []Wanted - handles map[Wanted]func(string, json.RawMessage) error - gets int - value json.RawMessage + refuse bool + watched []Wanted + handles []func(string, json.RawMessage) error + current json.RawMessage // handed to a new watch before Watch returns, as the SDK does + gets int + value json.RawMessage + keyFound bool } func (b *fakeBus) bus() Bus { @@ -53,18 +55,19 @@ func (b *fakeBus) bus() Bus { 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.watched = append(b.watched, Wanted{state, key}) + b.handles = append(b.handles, h) + if b.current != nil { + return h("put", b.current) } - b.handles[w] = h return nil }, - Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.value != nil, nil }, + Get: func(string, string) (json.RawMessage, bool, error) { b.gets++; return b.value, b.keyFound, nil }, } } +func (b *fakeBus) latest() func(string, json.RawMessage) error { return b.handles[len(b.handles)-1] } + func barFiles(t *testing.T, content string) []string { t.Helper() f := filepath.Join(t.TempDir(), "bottom-bar.toml") @@ -74,57 +77,139 @@ func barFiles(t *testing.T, content string) []string { 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"`) +type follow struct { + t *testing.T + now time.Time + said []string + bus *fakeBus + dir string + f *Follower + bar []string +} - // 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) - } - b.refuse = false - f.Round(bar) - if len(b.watched) != 1 { - t.Fatalf("watched %v", b.watched) - } +func newFollow(t *testing.T) *follow { + x := &follow{t: t, now: time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), bus: &fakeBus{}, dir: t.TempDir()} + x.f = &Follower{Dir: x.dir, Bus: x.bus.bus(), Now: func() time.Time { return x.now }, Say: func(s string) { x.said = append(x.said, s) }} + x.bar = barFiles(t, `command = "~/.local/bin/i3status-value power.draw laptop"`) + return x +} - // 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) +func (x *follow) shown() string { + b, err := os.ReadFile(filepath.Join(x.dir, "power.draw@laptop")) + if err != nil { + return "" } + return string(b) +} - // 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") +func TestTheValueAWatchHandsOverAtOnceIsKept(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"18.2 W machine"}`) + x.f.Round(x.bar) + if x.shown() != "18.2 W machine\n" { + t.Fatalf("the value handed over before Watch returned was dropped: %q", x.shown()) } - if err := b.handles[Wanted{"power.draw", "laptop"}]("put", b.value); err != nil { - t.Fatal(err) +} + +func TestARefusedWatchIsSaidAndTriedAgain(t *testing.T) { + x := newFollow(t) + x.bus.refuse = true + x.f.Round(x.bar) + if len(x.said) != 1 || !strings.Contains(x.said[0], "cannot watch power.draw laptop: not granted") { + t.Fatalf("%v", x.said) } - if _, err := os.Stat(filepath.Join(dir, "power.draw@laptop")); !os.IsNotExist(err) { + x.bus.refuse = false + x.f.Round(x.bar) + if len(x.bus.watched) != 1 { + t.Fatalf("watched %v", x.bus.watched) + } +} + +func TestASilentWatchWhoseValueWentOnIsWatchedAgainAndSaidOnce(t *testing.T) { + x := newFollow(t) + x.f.Round(x.bar) + x.bus.value, x.bus.keyFound = json.RawMessage(`{"text":"9.0 W CPU"}`), true + for i := 0; i < 3; i++ { + x.now = x.now.Add(Silent + time.Second) + x.f.Round(x.bar) + } + if len(x.bus.watched) != 4 || x.shown() != "9.0 W CPU\n" { + t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown()) + } + dead := 0 + for _, s := range x.said { + if strings.Contains(s, "watching it again") { + dead++ + } + } + if dead != 1 { + t.Fatalf("a dead watch said %d times: %v", dead, x.said) + } + // What an old watch hands over is dropped; the current one's is kept. + if err := x.bus.handles[0]("put", json.RawMessage(`{"text":"old"}`)); err != nil || x.shown() != "9.0 W CPU\n" { + t.Fatalf("an old watch wrote: %q %v", x.shown(), err) + } + if err := x.bus.latest()("put", json.RawMessage(`{"text":"10.0 W CPU"}`)); err != nil || x.shown() != "10.0 W CPU\n" { + t.Fatalf("the current watch did not: %q %v", x.shown(), err) + } +} + +func TestAnExpiredKeyIsExpiredNotADeadWatch(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`) + x.f.Round(x.bar) + x.bus.current, x.bus.keyFound = nil, false + for i := 0; i < 3; i++ { + x.now = x.now.Add(Silent + time.Second) + x.f.Round(x.bar) + } + if len(x.bus.watched) != 1 || x.shown() != "" { + t.Fatalf("watched %d times, shows %q", len(x.bus.watched), x.shown()) + } + for _, s := range x.said { + if strings.Contains(s, "watching it again") { + t.Fatalf("an expired key was taken for a dead watch: %v", x.said) + } + } + if n := strings.Count(strings.Join(x.said, "|"), "expired"); n != 1 { + t.Fatalf("expiry said %d times: %v", n, x.said) + } +} + +func TestARemovedBlockIsNoLongerKept(t *testing.T) { + x := newFollow(t) + x.bus.current = json.RawMessage(`{"text":"9.0 W CPU"}`) + x.f.Round(x.bar) + x.f.Round(barFiles(t, "no values here")) + if x.shown() != "" || !strings.Contains(x.said[len(x.said)-1], "no longer kept") { + t.Fatalf("%q %v", x.shown(), x.said) + } + if err := x.bus.latest()("put", json.RawMessage(`{"text":"after"}`)); err != nil || x.shown() != "" { t.Fatal("a stopped watch still writes") } - if !strings.Contains(said[len(said)-1], "no longer kept") { - t.Fatalf("%v", said) +} + +func TestAWatchAndAReadWritingAtOnceLeaveOneWholeValue(t *testing.T) { + dir := t.TempDir() + w := Wanted{"power.draw", "laptop"} + done := make(chan error, 20) + for i := 0; i < 20; i++ { + go func(i int) { + done <- keep(dir, w, "put", json.RawMessage(`{"text":"value `+strings.Repeat("x", i)+`"}`)) + }(i) + } + for i := 0; i < 20; i++ { + if err := <-done; err != nil { + t.Fatal(err) + } + } + b, _ := os.ReadFile(filepath.Join(dir, "power.draw@laptop")) + if !strings.HasPrefix(string(b), "value ") || strings.Count(string(b), "\n") != 1 { + t.Fatalf("%q", b) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("temporary files left: %v", entries) } } From 5d7d9020b8c71961fbc5d96e5680019fe9983e8e Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 8 Oct 2026 16:20:45 +0200 Subject: [PATCH 6/6] Say the laptop's scripts unparsed where the build seat has no bash The Go toolchain the repository check runs in carries no bash, so the scripts' syntax check failed there on a missing interpreter, not a broken script. --- modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go index 7e986c6c..410c78ac 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go @@ -113,6 +113,12 @@ func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) { if len(scripts) == 0 { t.Fatal("no scripts") } + // The scripts are bash's. The build seat's Go toolchain carries no bash, so there they are said as + // not parsed rather than failed: a missing interpreter is not a broken script. + if _, err := exec.LookPath("bash"); err != nil { + t.Logf("NOT PARSED: no bash on this machine, so the scripts' syntax was not checked") + return + } for _, s := range scripts { if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil { t.Errorf("%s: %v %s", s, err, out)