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)
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
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.
This commit is contained in:
@@ -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
|
||||
|
||||
+57
-12
@@ -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/<unit>.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 <dir>` 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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user