Judge a granted group by number, grant nothing a group cannot write, and keep the look bounded (hq ADR 0312, issue 537 review)
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered

The engine is built without cgo, so a check by name missed a root group a name
service holds or one number carrying another name: the root groups are resolved
to numbers (now with kmem and shadow) and a number named nothing is refused for
containers too. A group without write is granted nothing (§2). The look reads
each path off the lock within five seconds, so a dead network mount cannot stop
every health statement. A grant's account may not start with a dash. A group a
record leaves for another asker is handed to it, so it is still taken back when
the last asker goes (§5). The scheduler skips paths issue 339's guard refuses.
This commit is contained in:
2026-10-12 12:53:42 +02:00
parent f4efc3cad0
commit c433e76de2
9 changed files with 409 additions and 61 deletions
+65 -7
View File
@@ -34,10 +34,18 @@ type accessJudge struct {
accesses []grantedAccess
// was is each access's last verdict, with since when and how many looks in a row found it unhealthy.
was map[string]link.ResourceHealth
// reading guards inFlight: every path whose read has not returned yet.
reading sync.Mutex
inFlight map[string]bool
}
// accesses is the one access judge on this machine.
var accesses = &accessJudge{was: map[string]link.ResourceHealth{}}
var accesses = newAccessJudge()
func newAccessJudge() *accessJudge {
return &accessJudge{was: map[string]link.ResourceHealth{}, inFlight: map[string]bool{}}
}
// set is what the declaration just applied asks: every read-write access of a module that names a grant, not
// held as found. An access no longer declared is forgotten.
@@ -71,17 +79,58 @@ func (j *accessJudge) set(d *declaration.Declaration, held map[string]bool) {
}
}
// lookBound is how long a look waits for one accessed path to answer. A hard network mount whose server is down
// blocks a stat for ever, and one access must never stop every health statement this machine makes.
var lookBound = 5 * time.Second
// pathGroupBounded reads a path's group within lookBound, outside any lock. A read that has not answered is said
// so; while it still hangs, later looks do not start another on the same path, so a dead mount costs one blocked
// read, not one per look.
func (j *accessJudge) pathGroupBounded(path string) (apply.PathGroup, bool, error) {
j.reading.Lock()
if j.inFlight[path] {
j.reading.Unlock()
return apply.PathGroup{}, false, nil
}
j.inFlight[path] = true
j.reading.Unlock()
type read struct {
g apply.PathGroup
err error
}
done := make(chan read, 1)
go func() {
g, err := apply.GroupOfPath(path)
j.reading.Lock()
delete(j.inFlight, path)
j.reading.Unlock()
done <- read{g, err}
}()
select {
case r := <-done:
return r.g, true, r.err
case <-time.After(lookBound):
return apply.PathGroup{}, false, nil
}
}
// look reads every access now: healthy when its group may write and may be granted, unhealthy saying why
// otherwise. Whether any verdict changed since the last look, for the loop to say it.
// otherwise. Whether any verdict changed since the last look, for the loop to say it. The paths are read with no
// lock held, each within lookBound.
func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(j.accesses))
for _, a := range j.accesses {
as := append([]grantedAccess(nil), j.accesses...)
j.mu.Unlock()
type verdict struct{ state, reason string }
read := make([]verdict, len(as))
for i, a := range as {
state, reason := link.StateHealthy, ""
g, err := apply.GroupOfPath(a.path)
g, answered, err := j.pathGroupBounded(a.path)
switch {
case !answered:
state, reason = link.StateUnhealthy, "its path does not answer: reading which group owns it did not "+
"return within "+lookBound.String()
case err != nil:
state, reason = link.StateUnhealthy, "its group cannot be read: "+strings.ReplaceAll(err.Error(), a.path, "the path")
case apply.GrantRefusal(g) != nil:
@@ -89,6 +138,15 @@ func (j *accessJudge) look(now time.Time) ([]link.ResourceHealth, bool) {
case !g.Writable:
state, reason = link.StateUnhealthy, apply.CannotWriteWords(g)
}
read[i] = verdict{state, reason}
}
j.mu.Lock()
defer j.mu.Unlock()
changed := false
out := make([]link.ResourceHealth, 0, len(as))
for i, a := range as {
state, reason := read[i].state, read[i].reason
v := link.ResourceHealth{Module: a.module, Resource: a.id, Kind: link.KindAccess, Target: a.path,
State: state, Reason: reason, Since: now.UTC()}
if was, had := j.was[a.id]; had && was.State == state && was.Reason == reason {
+61 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"strings"
"sync/atomic"
"testing"
"time"
@@ -23,6 +24,9 @@ func TestAnAccessWhoseGroupCannotWriteIsStatedUnhealthyWithTheOperatorsRemedy(t
was := apply.GroupOfPath
apply.GroupOfPath = func(path string) (apply.PathGroup, error) { return groups[path], nil }
t.Cleanup(func() { apply.GroupOfPath = was })
wasRoot := apply.RootGroupIDs
apply.RootGroupIDs = func() map[int]string { return map[int]string{0: "root"} }
t.Cleanup(func() { apply.RootGroupIDs = wasRoot })
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"nzbget.downloads","type":"access","path":"` + spool + `","mode":"read-write","grant":{"account":"operator"}},
@@ -32,7 +36,7 @@ func TestAnAccessWhoseGroupCannotWriteIsStatedUnhealthyWithTheOperatorsRemedy(t
if err != nil {
t.Fatal(err)
}
j := &accessJudge{was: map[string]link.ResourceHealth{}}
j := newAccessJudge()
j.set(d, nil)
at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC)
vs, changed := j.look(at)
@@ -76,3 +80,59 @@ func TestAnAccessWhoseGroupCannotWriteIsStatedUnhealthyWithTheOperatorsRemedy(t
}
}
}
// A path that does not answer — a hard network mount whose server is down — is stated unhealthy within the bound,
// and stops neither this look nor the next: one blocked read per path, never one per look.
func TestAnAccessWhosePathDoesNotAnswerIsStatedWithinTheBound(t *testing.T) {
hung, library := t.TempDir(), t.TempDir()
release, finished := make(chan struct{}), make(chan struct{})
var started atomic.Int32
was, wasBound := apply.GroupOfPath, lookBound
apply.GroupOfPath = func(path string) (apply.PathGroup, error) {
if path == hung {
started.Add(1)
<-release
defer close(finished)
}
return apply.PathGroup{GID: 1001, Name: "media", Writable: true}, nil
}
lookBound = 50 * time.Millisecond
wasRoot := apply.RootGroupIDs
apply.RootGroupIDs = func() map[int]string { return map[int]string{0: "root"} }
t.Cleanup(func() { apply.GroupOfPath, lookBound, apply.RootGroupIDs = was, wasBound, wasRoot })
// Run first: the read that hangs is let go and has returned before anything it read is put back.
t.Cleanup(func() { close(release); <-finished })
d, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
{"id":"nzbget.downloads","type":"access","path":"` + hung + `","mode":"read-write","grant":{}},
{"id":"importer.music","type":"access","path":"` + library + `","mode":"read-write","grant":{}}]}`))
if err != nil {
t.Fatal(err)
}
j := newAccessJudge()
j.set(d, nil)
for look := 0; look < 3; look++ {
done := make(chan []link.ResourceHealth, 1)
go func() { vs, _ := j.look(time.Now()); done <- vs }()
select {
case vs := <-done:
for _, v := range vs {
switch v.Resource {
case "nzbget.downloads":
if v.State != link.StateUnhealthy || !strings.Contains(v.Reason, "does not answer") {
t.Errorf("look %d: a path that does not answer is stated %+v", look, v)
}
case "importer.music":
if v.State != link.StateHealthy {
t.Errorf("look %d: the path that answers is stated %+v", look, v)
}
}
}
case <-time.After(5 * time.Second):
t.Fatalf("look %d did not return while one path hangs", look)
}
}
if n := started.Load(); n != 1 {
t.Errorf("%d reads were started on the path that hangs; want one", n)
}
}