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.
246 lines
7.1 KiB
Go
246 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// The values other modules keep on the bus for a block they contributed (novox/hq ADR 0260).
|
|
//
|
|
// A block that `shows` a state is rendered as a custom block running `i3status-value <state> <key>`.
|
|
// i3status-rs cannot reach the bus, and this bundle can: the node's runtime launched it, and the mesh
|
|
// granted this module a read of 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.
|
|
|
|
// 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 }
|
|
|
|
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 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 op == "delete" || op == "purge" || value == nil {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
var v struct {
|
|
Text string `json:"text"`
|
|
At string `json:"at"`
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
tmp := path + ".new"
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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"))
|
|
f.Round(files)
|
|
time.Sleep(FollowEvery)
|
|
}
|
|
}
|