mesh/merge-gate pass: builds asus-zephyrus-g14, i3status-rust, power → ace, g14, novox, shanks; no bus step; every machine composes with the change as it d…
mesh/repo-check fail: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
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.
295 lines
9.1 KiB
Go
295 lines
9.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. 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 {
|
|
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, 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.Name(), at, at); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
// 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.
|
|
type following struct {
|
|
// 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.
|
|
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 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) {
|
|
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]
|
|
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 stopped:
|
|
f.mu.Lock()
|
|
fw.stopped = false
|
|
f.mu.Unlock()
|
|
f.start(w)
|
|
case silent:
|
|
f.silent(w)
|
|
}
|
|
}
|
|
f.mu.Lock()
|
|
var gone []Wanted
|
|
for w, fw := range f.of {
|
|
if !wanted[w] && !fw.stopped {
|
|
fw.stopped = true
|
|
fw.gen++
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
current := f.of[w] == fw && fw.gen == gen && !fw.stopped
|
|
if current {
|
|
fw.last, fw.dead, fw.expired = f.Now(), false, false
|
|
}
|
|
f.mu.Unlock()
|
|
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))
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
fw := f.of[w]
|
|
fw.last = f.Now()
|
|
wasDead, wasExpired := fw.dead, fw.expired
|
|
f.mu.Unlock()
|
|
switch {
|
|
case err != nil:
|
|
f.Say(fmt.Sprintf("cannot read %s %s: %v", w.State, w.Key, err))
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|