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) } }