Compare commits

..
7 Commits
Author SHA1 Message Date
jschoubben a073aeae5e Merge remote-tracking branch 'origin/main' into feat/try-speed 2026-10-25 03:17:34 +01:00
jschoubben 99407c37df Merge remote-tracking branch 'origin/main' into feat/try-speed 2026-10-25 01:29:22 +02:00
jschoubben 9b408db24a buildstep: run a check's levels side by side and answer them in level order, so the integration level no longer waits for the unit level and the first failing level is still named first (issue 1921, try speed S10) 2026-10-25 01:22:50 +02:00
jschoubben 571ac521f0 delivery: keep a merged member in its group's candidate, marked merged, so the heads an Approve covers hold until every member merged (issue 1921, issue 1791, try speed S9) 2026-10-25 01:17:16 +02:00
jschoubben 4a12ccb78d buildstep: deepen a merge preview's history from the mirror when it holds the trunk commit, so the preview fetches no history from the forge (issue 1921, try speed S6) 2026-10-25 01:08:39 +02:00
jschoubben cb548a6a58 buildstep: keep what one command of an ask writes into the caches for its later commands, so vet, unit tests and the integration compile build each package once; the shared cache stays unchanged (issue 1921, try speed S2) 2026-10-25 00:58:46 +02:00
jschoubben bc16740feb Run every test step of a check's level after a failure, and let a verdict name every failed test (issue 1921)
A check ask stopped its level at the first test step that did not pass,
so the packages after the changed ones never ran and a check found one
fault per round. Every step now runs; the level still fails, and no
later level runs (ADR 0484 clause 13). The verdict gains Failures, every
failed test with its first line, at most MaxFailures and a count.
2026-10-24 23:41:54 +02:00
490 changed files with 14252 additions and 39655 deletions
+6 -32
View File
@@ -24,13 +24,16 @@ const Event = "states"
// account may already publish to and the events stream keeps. // account may already publish to and the events stream keeps.
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event } func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
// SubjectAll is every module's announcement. // SubjectAll is every module's announcement, what the controller consumes.
const SubjectAll = "mesh.mod.*.event." + Event const SubjectAll = "mesh.mod.*.event." + Event
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
const KeptBucket = "mesh-controller_states-announced"
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of // Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1). // its buckets' names (catalogue.StateProblems).
func Key(module string) (string, error) { func Key(module string) (string, error) {
if !name.MatchString(module) { if !name.MatchString(module) {
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module) return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
@@ -43,10 +46,6 @@ func Key(module string) (string, error) {
type State struct { type State struct {
Name string `json:"name"` Name string `json:"name"`
Readable bool `json:"readable,omitempty"` Readable bool `json:"readable,omitempty"`
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
Readers []string `json:"readers,omitempty"`
} }
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted // States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
@@ -68,26 +67,6 @@ func (s States) Keeps(state string) bool {
return false return false
} }
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
// when it names any.
func (s States) ReadableBy(state, reader string) bool {
for _, k := range s.States {
if k.Name != state {
continue
}
if len(k.Readers) > 0 {
for _, r := range k.Readers {
if r == reader {
return true
}
}
return false
}
return k.Readable
}
return false
}
// Readable says whether the module announced the state readable by others. // Readable says whether the module announced the state readable by others.
func (s States) Readable(state string) bool { func (s States) Readable(state string) bool {
for _, k := range s.States { for _, k := range s.States {
@@ -114,11 +93,6 @@ func (s *States) check() error {
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name) return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
} }
seen[k.Name] = true seen[k.Name] = true
for _, r := range k.Readers {
if !name.MatchString(r) {
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
}
}
} }
return nil return nil
} }
+5 -9
View File
@@ -9,8 +9,7 @@ import (
// own event subject, in a versioned value the controller keeps by module. // own event subject, in a versioned value the controller keeps by module.
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) { func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{ data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}, {Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}})
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -25,23 +24,21 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
for _, s := range back.States { for _, s := range back.States {
names = append(names, s.Name) names = append(names, s.Name)
} }
if strings.Join(names, ",") != "assignments,hashes,own,plans-building" { if strings.Join(names, ",") != "assignments,own,plans-building" {
t.Fatalf("the states are not sorted: %v", names) t.Fatalf("the states are not sorted: %v", names)
} }
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") { if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
t.Fatal("Readable does not say what was announced readable") t.Fatal("Readable does not say what was announced readable")
} }
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
t.Fatal("ReadableBy does not say who was named")
}
if !back.Keeps("own") || back.Keeps("absent") { if !back.Keeps("own") || back.Keeps("absent") {
t.Fatal("Keeps does not say what was announced") t.Fatal("Keeps does not say what was announced")
} }
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" { if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll) t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
} }
if KeptBucket != "mesh-controller_states-announced" {
t.Fatalf("the kept bucket is %q", KeptBucket)
}
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" { if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
t.Fatalf("Key = %q, %v", k, err) t.Fatalf("Key = %q, %v", k, err)
} }
@@ -60,7 +57,6 @@ func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`}, {States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
{States{Module: "a", States: []State{{Name: ""}}}, `""`}, {States{Module: "a", States: []State{{Name: ""}}}, `""`},
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"}, {States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
} { } {
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) { if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says) t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
-5
View File
@@ -571,8 +571,3 @@ type ProofAnswer struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Words string `json:"words"` Words string `json:"words"`
} }
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
// node's engine account alone, so no engine acts on another node's warrant.
func EngineAsker(node string) string { return "node-engine-" + node }
-215
View File
@@ -1,215 +0,0 @@
// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else
// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu
// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power").
//
// Three parts, each with one owner:
//
// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer
// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in
// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts.
// Interactive and service work always comes first; builds take the cycles nothing else wants.
// - **One cap per node, shared.** The node's share of its processors is one value, the node-engine's own config
// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the
// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module.
// - **Threads.** The quota bounds the total; threads only size each step. A step gets the share divided by its own
// module's capacity (Threads), from the share the composer hands as a word (ShareWord): GOMAXPROCS sets every go
// command's -p, the compiler's concurrency, a test binary's processors and -parallel. A module given no word sets
// no thread limit at all: without the slice there is nothing to size a step to, and a limit then would only slow
// builds.
package buildcpu
import (
"os"
"runtime"
"strconv"
"strings"
)
// ShareSetting is the node-engine's config value of the node's processor share for its builds, in percent of the
// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3).
const ShareSetting = "build-cpu-share"
// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for
// itself whatever builds.
const DefaultPercent = 50
// ShareWord is the word the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, in percent.
const ShareWord = "MESH_BUILD_CPU_SHARE"
// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one
// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step.
func Quota(percent, cores int) int {
q := percent * cores
if q < 100 {
q = 100
}
return q
}
// Threads is how many threads one step may run at once: its part of the share when its own module runs slots steps at
// once, at least one. The slice's quota bounds the steps of all the node's build modules together; this only sizes one.
func Threads(percent, cores, slots int) int {
if slots < 1 {
slots = 1
}
if percent < 1 || percent > 100 {
percent = DefaultPercent
}
t := cores * percent / 100 / slots
if t < 1 {
t = 1
}
return t
}
// Limits is what one step is held to on this node, as a status tool says it.
type Limits struct {
// Percent is the node's share in percent of its processors, and From where it was read: ShareWord, or "none" when
// the composer gave no share and a step runs without a thread limit.
Percent int `json:"share-percent,omitempty"`
From string `json:"share-from"`
// Cores is the machine's processor count.
Cores int `json:"cores"`
// Slots is this module's own capacity on the node: steps at once.
Slots int `json:"slots"`
// Threads is what each step is given (Threads); 0 is no limit.
Threads int `json:"threads-per-step"`
}
// FromWords is the limits of a step from the share the composer gave this process (ShareWord) and the module's own
// capacity. Without a usable share there is no limit: Threads is 0.
func FromWords(getenv func(string) string, own int) Limits {
if own < 1 {
own = 1
}
l := Limits{From: "none", Cores: runtime.NumCPU(), Slots: own}
if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 {
l.Percent, l.From = n, ShareWord
l.Threads = Threads(n, l.Cores, own)
}
return l
}
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as
// KEY=value words to append to a command's environment; nothing for 0 threads, no limit.
func Env(threads int) []string {
if threads < 1 {
return nil
}
t := strconv.Itoa(threads)
return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t}
}
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight,
// idle mark and quota. A field the machine does not answer is left empty and Unread says which.
//
// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under
// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds.
type InForce struct {
Nice int `json:"nice"`
Cgroup string `json:"cgroup,omitempty"`
Unit Group `json:"unit"`
Slice Group `json:"slice"`
// Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none.
Quota int `json:"quota-percent,omitempty"`
Unread []string `json:"unread,omitempty"`
}
// Group is one cgroup's controls.
type Group struct {
Path string `json:"path,omitempty"`
CPUIdle string `json:"cpu-idle,omitempty"`
CPUWeight string `json:"cpu-weight,omitempty"`
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
CPUMax string `json:"cpu-max,omitempty"`
IOWeight string `json:"io-weight,omitempty"`
}
// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere.
var cgroupRoot = "/sys/fs/cgroup"
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
var procSelfCgroup = "/proc/self/cgroup"
// procSelfStat is this process's stat file. A variable so a test can point it elsewhere.
var procSelfStat = "/proc/self/stat"
// ReadInForce reads what the kernel holds this process to.
func ReadInForce() InForce {
var f InForce
f.Nice = nice()
raw, err := os.ReadFile(procSelfCgroup)
if err != nil {
f.Unread = append(f.Unread, "cgroup: "+err.Error())
return f
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if strings.HasPrefix(line, "0::") {
f.Cgroup = strings.TrimPrefix(line, "0::")
}
}
if f.Cgroup == "" {
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
return f
}
read := func(path string) Group {
g := Group{Path: path}
value := func(name string) string {
b, err := os.ReadFile(cgroupRoot + path + "/" + name)
if err != nil {
f.Unread = append(f.Unread, path+"/"+name+": "+err.Error())
return ""
}
return strings.TrimSpace(string(b))
}
g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight")
return g
}
f.Unit = read(f.Cgroup)
if i := strings.LastIndex(f.Cgroup, "/"); i > 0 {
f.Slice = read(f.Cgroup[:i])
}
for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} {
if q > 0 && (f.Quota == 0 || q < f.Quota) {
f.Quota = q
}
}
return f
}
// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0.
func quotaOf(cpuMax string) int {
fields := strings.Fields(cpuMax)
if len(fields) != 2 || fields[0] == "max" {
return 0
}
q, err1 := strconv.Atoi(fields[0])
p, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil || p <= 0 {
return 0
}
return q * 100 / p
}
// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
func nice() int {
raw, err := os.ReadFile(procSelfStat)
if err != nil {
return 0
}
s := string(raw)
i := strings.LastIndex(s, ")")
if i < 0 {
return 0
}
fields := strings.Fields(s[i+1:])
// After the command: state is field 3, so nice (field 19) is index 16.
if len(fields) < 17 {
return 0
}
n, _ := strconv.Atoi(fields[16])
return n
}
-90
View File
@@ -1,90 +0,0 @@
package buildcpu
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
// The node's share as a quota, the threads one step is given for its own module's capacity, and the word the composer
// hands, in one table (ADR 0553, issue 1806).
func TestShareQuotaAndThreads(t *testing.T) {
for _, c := range []struct {
name string
percent, cores, slots int
quota, threads int
}{
{"shanks: half of 24, three slots", 50, 24, 3, 1200, 4},
{"ace: three quarters of 24, two slots", 75, 24, 2, 1800, 9},
{"g14: half of 16, one slot", 50, 16, 1, 800, 8},
{"one processor at least", 10, 2, 4, 100, 1},
{"no slots counts as one", 100, 4, 0, 400, 4},
{"a share out of range is the default", 0, 8, 1, 0, 4},
} {
if c.quota != 0 {
if q := Quota(c.percent, c.cores); q != c.quota {
t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota)
}
}
if th := Threads(c.percent, c.cores, c.slots); th != c.threads {
t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads)
}
}
l := FromWords(func(k string) string { return map[string]string{ShareWord: "75"}[k] }, 2)
if l.Percent != 75 || l.From != ShareWord || l.Slots != 2 || l.Threads != Threads(75, runtime.NumCPU(), 2) {
t.Errorf("from the word: %+v", l)
}
// Told nothing usable, a step has no thread limit: nothing in its environment.
for _, word := range []string{"", "250", "lots"} {
l := FromWords(func(string) string { return word }, 3)
if l.Threads != 0 || l.From != "none" || Env(l.Threads) != nil {
t.Errorf("share %q: %+v, env %v", word, l, Env(l.Threads))
}
}
if got := Env(3); !reflect.DeepEqual(got, []string{"GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) {
t.Errorf("Env = %q", got)
}
}
// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed.
func TestReadInForce(t *testing.T) {
dir := t.TempDir()
cg := filepath.Join(dir, "cg")
slice := filepath.Join(cg, "meshyield.slice")
unit := filepath.Join(slice, "mesh-build-go-runtime.service")
if err := os.MkdirAll(unit, 0o755); err != nil {
t.Fatal(err)
}
for at, files := range map[string]map[string]string{
slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"},
unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"},
} {
for name, v := range files {
if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil {
t.Fatal(err)
}
}
}
self := filepath.Join(dir, "cgroup")
stat := filepath.Join(dir, "stat")
_ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644)
// A command with a space and a parenthesis in it, then nice 19 in field 19.
_ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644)
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
t.Cleanup(func() {
cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat"
})
got := ReadInForce()
want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service",
Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100",
CPUMax: "max 100000", IOWeight: "default 100"},
Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000",
IOWeight: "default 1"},
Quota: 1200}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ReadInForce = %+v\nwant %+v", got, want)
}
}
+46 -106
View File
@@ -4,11 +4,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"path" "path"
"slices" "slices"
"sort" "sort"
"strings" "strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -25,7 +25,7 @@ type StepFunc func(ctx context.Context, args map[string]any) (any, error)
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's. // AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
type AskSteps struct { type AskSteps struct {
Fetch, Build, Copy, Digest, Test StepFunc Fetch, Build, Copy, Digest, Test StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing // Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
// (an image or an archive names none). // (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error) Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is // Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
@@ -35,38 +35,19 @@ type AskSteps struct {
// the module's sweep. // the module's sweep.
Release func(workspace string) error Release func(workspace string) error
Logf func(format string, args ...any) Logf func(format string, args ...any)
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
Has func(need string) bool
} }
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer. // RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) { func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
if ask.Build != nil { if ask.Build != nil {
job := *ask.Build return steps.build(ctx, *ask.Build)
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.build(ctx, job)
} }
if ask.Check != nil { if ask.Check != nil {
job := *ask.Check return steps.check(ctx, *ask.Check)
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.check(ctx, job)
} }
return nil, Refuse("the ask %s holds no job", ask.ID) return nil, Refuse("the ask %s holds no job", ask.ID)
} }
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
// fetches nothing, is left as it is.
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
if len(packages) == 0 || fetch == nil {
return fetch
}
out := maps.Clone(fetch)
out["packages"] = maps.Clone(packages)
return out
}
func raw(v any) (json.RawMessage, error) { func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok { if r, ok := v.(json.RawMessage); ok {
return r, nil return r, nil
@@ -87,11 +68,6 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
if s.Fetch == nil { if s.Fetch == nil {
return nil, fetched{}, Refuse("this build module fetches nothing") return nil, fetched{}, Refuse("this build module fetches nothing")
} }
if _, merges := args["base"]; merges {
Phase(ctx, "fetch and merge")
} else {
Phase(ctx, "fetch")
}
out, err := s.Fetch(ctx, args) out, err := s.Fetch(ctx, args)
if err != nil { if err != nil {
return nil, fetched{}, err return nil, fetched{}, err
@@ -107,32 +83,19 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
return r, f, nil return r, f, nil
} }
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
if want == nil || s.Toolchain == nil {
return nil, nil
}
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != want.Version || tc.Recipe != want.Recipe {
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
want.Version, want.Recipe, tc.Version, tc.Recipe)}
}
return &tc, nil
}
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) { func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
answer := delivery.BuildAnswer{} answer := delivery.BuildAnswer{}
tc, err := s.onToolchain(ctx, job.Toolchain) if job.Toolchain != nil && s.Toolchain != nil {
if err != nil { tc, err := s.Toolchain(ctx)
return nil, err if err != nil {
return nil, err
}
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
}
answer.Toolchain = &tc
} }
answer.Toolchain = tc
var f fetched var f fetched
defer func() { s.release(f.Workspace) }() defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts { for _, a := range job.Artifacts {
@@ -161,7 +124,6 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
if s.Copy == nil { if s.Copy == nil {
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact) return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
} }
Phase(ctx, "copy "+a.Artifact)
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From}) out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
} else { } else {
if s.Build == nil { if s.Build == nil {
@@ -171,7 +133,6 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
if job.Publish { if job.Publish {
args["publish"] = true args["publish"] = true
} }
Phase(ctx, "build "+a.Artifact)
out, err = s.Build(ctx, args) out, err = s.Build(ctx, args)
} }
if err != nil { if err != nil {
@@ -194,9 +155,6 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
} }
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) { func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
return nil, err
}
r, f, err := s.fetch(ctx, job.Fetch) r, f, err := s.fetch(ctx, job.Fetch)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,16 +171,36 @@ func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error)
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) { if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
runner = job.Runner runner = job.Runner
} }
for _, level := range job.Levels { // The levels run side by side (issue 1921, try speed S10; the operator's yes to D1, ADR 0541): the integration level
la := delivery.LevelAnswer{Level: level} // does not wait for the unit level. They are answered in their order, so the first level that did not pass is still
passed, err := s.level(ctx, job, level, f, runner, &la) // the first failure the asker reads (ADR 0484 clause 13); the levels after it are kept in the answer for the log.
answer.Levels = append(answer.Levels, la) type ran struct {
if err != nil { la delivery.LevelAnswer
return nil, err passed bool
} err error
if !passed { }
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13) runs := make([]ran, len(job.Levels))
var wg sync.WaitGroup
for i, level := range job.Levels {
wg.Add(1)
go func() {
defer wg.Done()
runs[i].la = delivery.LevelAnswer{Level: level}
runs[i].passed, runs[i].err = s.level(ctx, job, level, f, runner, &runs[i].la)
}()
}
wg.Wait()
failed := false
for _, r := range runs {
if r.err != nil {
if failed {
continue // a level after the first failure that could not run: the failure is the answer
}
answer.Levels = append(answer.Levels, r.la)
return nil, r.err
} }
answer.Levels = append(answer.Levels, r.la)
failed = failed || !r.passed
} }
return answer, nil return answer, nil
} }
@@ -233,19 +211,9 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if runner != "" { if runner != "" {
args["runner"] = runner args["runner"] = runner
} }
if len(job.NodeNames) > 0 {
args[delivery.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12)
}
return args return args
} }
test := func(args map[string]any) (bool, error) { test := func(args map[string]any) (bool, error) {
what := "the whole level"
if dir, _ := args["module"].(string); dir != "" {
what = dir
} else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 {
what = fmt.Sprintf("%d packages", len(pkgs))
}
Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what))
out, err := s.Test(ctx, args) out, err := s.Test(ctx, args)
if err != nil { if err != nil {
return false, err return false, err
@@ -261,10 +229,8 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
_ = json.Unmarshal(r, &t) _ = json.Unmarshal(r, &t)
return t.Passed, nil return t.Passed, nil
} }
// The packages (or Node directories) whose tests the change touches run first, and a failure among them ends the level // Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
// there, so a change's own failing test is answered without the rest of the suite (issue 1289; ADR 0444). Past them every // level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
// test step of the level runs, also after one that did not pass, so one check names every failing test of the rest of
// the level and not only the first (issue 1921); the level passes only when each of its steps did.
all := true all := true
if s.Digest == nil { if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first. // A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
@@ -279,9 +245,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
} }
all = all && passed all = all && passed
} }
if !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
} }
return all, nil return all, nil
} }
@@ -289,9 +252,6 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if !keepRunner { if !keepRunner {
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2) runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
} }
if whole && len(job.Packages) > 0 {
todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4)
}
for _, priority := range []bool{true, false} { for _, priority := range []bool{true, false} {
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level}) args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
if job.Run != "" { if job.Run != "" {
@@ -317,10 +277,7 @@ func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string
if err != nil { if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err) return false, fmt.Errorf("the %s level: %w", level, err)
} }
all = all && passed // past the change's own packages the others still run (issue 1921) all = all && passed // the packages left still run (issue 1921)
if priority && !all {
return false, nil // the change's own tests failed: the others are not run (issue 1289)
}
} }
return all, nil return all, nil
} }
@@ -345,7 +302,6 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
if job.Run != "" { if job.Run != "" {
return nil, true, true return nil, true, true
} }
Phase(ctx, level+": digest")
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level})) out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
if err != nil { if err != nil {
if s.Logf != nil { if s.Logf != nil {
@@ -372,16 +328,9 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
} }
la.Digest = &d la.Digest = &d
only := map[string]bool{}
for _, p := range job.Packages {
only[p] = true
}
var todo []string var todo []string
for _, pd := range d.Packages { for _, pd := range d.Packages {
p := pd.Package p := pd.Package
if len(only) > 0 && !only[p] {
continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4)
}
if job.Kept && s.Kept != nil { if job.Kept && s.Kept != nil {
tests, found, err := s.Kept(ctx, level, pd.Digest) tests, found, err := s.Kept(ctx, level, pd.Digest)
if err != nil && s.Logf != nil { if err != nil && s.Logf != nil {
@@ -392,15 +341,6 @@ func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string,
continue continue
} }
} }
if needs := d.NeedsOf(p); len(delivery.Lacks(needs, s.Has)) > 0 {
// Its tests would only skip here (issue 890): it is left for a node that has what they need.
la.Unmet = append(la.Unmet, delivery.PackageNeeds{Package: p, Needs: needs})
if s.Logf != nil {
s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p,
strings.Join(needs, ", "), strings.Join(delivery.Lacks(needs, s.Has), ", "))
}
continue
}
todo = append(todo, p) todo = append(todo, p)
} }
return todo, false, false return todo, false, false
+60
View File
@@ -0,0 +1,60 @@
package buildstep
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1921/S10 (the operator's yes to D1, ADR 0541): a check's levels run side by side: the integration level has started
// before the unit level ends. The answer holds the levels in their order, so with the unit level failing it still names
// the unit level first; the integration level's answer is kept after it for the log.
func TestACheckRunsItsLevelsSideBySideAndAnswersThemInOrder(t *testing.T) {
integrationStarted := make(chan struct{})
var once sync.Once
var mu sync.Mutex
unitWaited := false
steps := AskSteps{
Fetch: func(context.Context, map[string]any) (any, error) {
return map[string]any{"workspace": "/w", "modules": []string{"."}}, nil
},
Test: func(ctx context.Context, args map[string]any) (any, error) {
switch args["level"] {
case delivery.LevelUnit:
select {
case <-integrationStarted:
mu.Lock()
unitWaited = true
mu.Unlock()
case <-time.After(2 * time.Second):
}
return map[string]any{"passed": false, "level": "unit"}, nil
default:
once.Do(func() { close(integrationStarted) })
return map[string]any{"passed": true, "level": "integration"}, nil
}
},
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !unitWaited {
t.Error("the integration level did not start while the unit level ran")
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 2 || a.Levels[0].Level != delivery.LevelUnit || a.Levels[1].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want unit then integration", a.Levels)
}
var first struct{ Passed bool }
if err := json.Unmarshal(a.Levels[0].Tests[0], &first); err != nil || first.Passed {
t.Errorf("the first level's result %s, want the unit level's failure", a.Levels[0].Tests[0])
}
}
+60 -133
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -16,18 +17,20 @@ const runCommit = "0123456789abcdef0123456789abcdef01234567"
// recorder is a build module's step tools that record each call. // recorder is a build module's step tools that record each call.
type recorder struct { type recorder struct {
mu sync.Mutex // a check's levels run side by side (issue 1921, S10)
calls []string calls []string
fetch map[string]any fetch map[string]any
tests map[string]bool // package or module directory -> passed tests map[string]bool // package or module directory -> passed
digests map[string]string digests map[string]string
needs []delivery.PackageNeeds
} }
func (r *recorder) steps() AskSteps { func (r *recorder) steps() AskSteps {
call := func(name string, answer func(args map[string]any) any) StepFunc { call := func(name string, answer func(args map[string]any) any) StepFunc {
return func(ctx context.Context, args map[string]any) (any, error) { return func(ctx context.Context, args map[string]any) (any, error) {
b, _ := json.Marshal(args) b, _ := json.Marshal(args)
r.mu.Lock()
r.calls = append(r.calls, name+" "+string(b)) r.calls = append(r.calls, name+" "+string(b))
r.mu.Unlock()
return answer(args), nil return answer(args), nil
} }
} }
@@ -36,10 +39,8 @@ func (r *recorder) steps() AskSteps {
Build: call("build", func(args map[string]any) any { Build: call("build", func(args map[string]any) any {
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)} return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
}), }),
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }), Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
Digest: call("digest", func(map[string]any) any { Digest: call("digest", func(map[string]any) any { return map[string]any{"level": "unit", "packages": r.digests} }),
return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs}
}),
Test: call("test", func(args map[string]any) any { Test: call("test", func(args map[string]any) any {
passed := true passed := true
if pkgs, ok := args["packages"].([]string); ok { if pkgs, ok := args["packages"].([]string); ok {
@@ -74,21 +75,15 @@ func TestRunAskBuilds(t *testing.T) {
steps := r.steps() steps := r.steps()
var released []string var released []string
steps.Release = func(ws string) error { released = append(released, ws); return nil } steps.Release = func(ws string) error { released = append(released, ws); return nil }
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were. out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
a := ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}) Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
out, err := RunAsk(context.Background(), steps, a)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, put := a.Build.Fetch["packages"]; put {
t.Error("the ask's fetch arguments were changed")
}
got := strings.Join(r.calls, "\n") got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"} want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + runCommit + `"},"repository":"novox/x"} fetch {"commit":"` + runCommit + `","repository":"novox/x"}
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"} build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}` build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if got != want { if got != want {
@@ -97,13 +92,13 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if len(released) != 1 || released[0] != "/w/1" { if len(released) != 1 || released[0] != "/w/1" {
t.Errorf("released %v, want the one workspace", released) t.Errorf("released %v, want the one workspace", released)
} }
answer := out.(delivery.BuildAnswer) a := out.(delivery.BuildAnswer)
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" { if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", answer) t.Fatalf("answer %+v", a)
} }
} }
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again. // A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
func TestRunAskRefusesAnotherToolchain(t *testing.T) { func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}} r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps() s := r.steps()
@@ -112,21 +107,15 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
} }
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, _, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}})) Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
var dec *Declining var ref *Refusal
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 { if !errors.As(err, &ref) || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls) t.Fatalf("err %v, calls %v", err, r.calls)
} }
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
if !errors.As(err, &dec) || len(r.calls) != 0 {
t.Fatalf("a check ask: err %v, calls %v", err, r.calls)
}
} }
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept // 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first and, when one of them fails, no other (issue 1289), and runs no // verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921); the levels run
// level after one that did not pass (ADR 0484 clause 13). // side by side and are answered in order, the first that did not pass first (ADR 0484 clause 13; issue 1921 S10).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) { func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{ r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}}, fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
@@ -138,67 +127,28 @@ func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := strings.Join(r.calls, "\n") var unit []string
for _, c := range r.calls {
if !strings.Contains(c, `"level":"integration"`) {
unit = append(unit, c)
}
}
got := strings.Join(unit, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"} want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"} digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}` test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
if got != want { if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want) t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
} }
a := out.(delivery.CheckAnswer) a := out.(delivery.CheckAnswer)
if len(a.Levels) != 1 { if len(a.Levels) != 2 || a.Levels[1].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want the unit level alone", a.Levels) t.Fatalf("levels %+v, want the unit level first, the integration level after it", a.Levels)
} }
u := a.Levels[0] u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 1 || u.Digest == nil { if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
t.Fatalf("the unit level %+v", u) t.Fatalf("the unit level %+v", u)
} }
// ADR 0567 §12: a check's node names reach the digest and test tools, for the names check (a kept verdict misses when
// they change).
r.calls = nil
if _, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, NodeNames: []string{"anchor"},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil || !strings.Contains(strings.Join(r.calls, "\n"), `digest {"level":"unit","node-names":["anchor"]`) ||
!strings.Contains(strings.Join(r.calls, "\n"), `test {"level":"unit","node-names":["anchor"]`) {
t.Fatalf("the node names are not handed to the digest and test tools: %v, %v", r.calls, err)
}
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
// tested on a node that has it.
for _, has := range []bool{false, true} {
r.calls, r.tests["x/pkg/b"] = nil, true
r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}}
steps := r.steps()
if has {
steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode }
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
u := out.(delivery.CheckAnswer).Levels[0]
tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`)
if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) {
t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls)
}
}
// A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would
// run whole (ADR 0552 §4).
for _, digests := range []map[string]string{r.digests, nil} {
r.calls, r.digests = nil, digests
steps := r.steps()
steps.Has = func(string) bool { return true }
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
u := out.(delivery.CheckAnswer).Levels[0]
if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 {
t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept)
}
}
} }
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says // ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
@@ -265,64 +215,41 @@ func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
} }
} }
// Issue 1289: a failure in the packages whose tests changed ends the level there, the others are not run, and no later level // Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
// runs. Issue 1921: past the change's own packages every package still runs, so two failing packages none of which the // failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
// change touched are two test results of the one level. A Node check does the same with its module directories. // check runs every module directory after one fails too.
func TestRunAskStopsAtTheChangesOwnFailureAndRunsTheRestAfterOthers(t *testing.T) { func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
for _, row := range []struct { r := &recorder{
name string fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
changed []string digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
tests map[string]bool tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
want []string // the packages tested, in order
}{
{"the change's own test fails: nothing else runs", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/b"}},
{"the change's own test passes: the rest runs, also after a failure", []string{"pkg/b/b_test.go"}, map[string]bool{"x/pkg/a": false, "x/pkg/b": true, "x/pkg/c": false}, []string{"x/pkg/b", "x/pkg/a x/pkg/c"}},
{"no test changed: every failure is named", nil, map[string]bool{"x/pkg/a": false, "x/pkg/b": false}, []string{"x/pkg/a x/pkg/b"}},
} {
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": row.changed}, tests: row.tests,
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"}}
if _, ok := row.tests["x/pkg/c"]; ok {
r.digests["x/pkg/c"] = "d-c"
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
var got []string
for _, c := range r.calls[1:] {
if strings.HasPrefix(c, "test ") {
var a struct{ Packages []string }
_ = json.Unmarshal([]byte(strings.TrimPrefix(c, "test ")), &a)
got = append(got, strings.Join(a.Packages, " "))
}
}
// the passing row goes on to the integration level, which tests the same packages again; the first level is what is judged
if n := len(row.want); len(got) < n || fmt.Sprint(got[:n]) != fmt.Sprint(row.want) {
t.Errorf("%s: tested %v, want %v first", row.name, got, row.want)
}
a := out.(delivery.CheckAnswer)
failed := false
for _, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
_ = json.Unmarshal(raw, &res)
failed = failed || !res.Passed
}
if failed && (len(a.Levels) != 1 || len(got) != len(row.want)) {
t.Errorf("%s: a failed level went on: levels %d, tests %v", row.name, len(a.Levels), got)
}
} }
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})) Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if a := out.(delivery.CheckAnswer); len(r.calls) != 2 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 1 { a := out.(delivery.CheckAnswer)
t.Fatalf("calls %v, levels %+v: want the changed directory b alone and no integration level", r.calls, a.Levels) if len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want the first level with both packages' results, in the order asked", a.Levels)
}
for i, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
t.Errorf("result %d %s, want a failure", i, raw)
}
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelUnit {
t.Fatalf("calls %v, levels %+v: want both directories of each level, the unit level first", r.calls, a.Levels)
} }
} }
-129
View File
@@ -1,129 +0,0 @@
package buildstep
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: the laptop builds only outside
// office hours, "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting is the node's window
// in its local time, a config value set on each node's layer of mesh-config (ADR 0567 §12: no repository names a node);
// outside it the capacity judged by is 0: steps running finish, no new one is taken. An empty value builds at any hour.
// The puller judges it at every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of
// another part).
// BuildHoursSetting is the build modules' setting of the node's build window: HH:MM-HH:MM in the node's local time; an
// end before its start crosses midnight (18:00-06:00).
const BuildHoursSetting = "build-hours"
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
type BuildWindow struct {
Start, End int
}
// String is the window as the setting writes it.
func (w BuildWindow) String() string {
return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60)
}
// Open says whether t, in its own location, falls in the window.
func (w BuildWindow) Open(t time.Time) bool {
m := t.Hour()*60 + t.Minute()
if w.Start < w.End {
return m >= w.Start && m < w.End
}
return m >= w.Start || m < w.End
}
// NextEdge is the first time after t, in t's location, at which the window opens or closes.
func (w BuildWindow) NextEdge(t time.Time) time.Time {
var next time.Time
for day := 0; day <= 1; day++ {
for _, m := range []int{w.Start, w.End} {
e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location())
if e.After(t) && (next.IsZero() || e.Before(next)) {
next = e
}
}
}
return next
}
// ParseBuildWindow reads a build-hours value, HH:MM-HH:MM: nil when it is empty.
func ParseBuildWindow(setting string) (*BuildWindow, error) {
setting = strings.TrimSpace(setting)
if setting == "" {
return nil, nil
}
from, to, ok := strings.Cut(setting, "-")
if !ok {
return nil, fmt.Errorf("the build hours %q are not HH:MM-HH:MM", setting)
}
start, err := minuteOf(from)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
end, err := minuteOf(to)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
if start == end {
return nil, fmt.Errorf("the build hours %q open and close at the same minute", setting)
}
return &BuildWindow{Start: start, End: end}, nil
}
// minuteOf reads HH:MM as minutes after midnight.
func minuteOf(s string) (int, error) {
var h, m int
s = strings.TrimSpace(s)
if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 {
return 0, fmt.Errorf("%q is not a time HH:MM", s)
}
return h*60 + m, nil
}
// NodeBuildHours is the node's build window, read from the module's settings file at path at every call, and why it is
// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that
// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety
// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through
// logf, and the puller writes it in the capacity record that build_usage shows.
func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) {
said := ""
say := func(why string) {
if logf != nil && why != said {
said = why
logf("%s, so %s builds at any hour", why, node)
}
}
fault := func(why string) (*BuildWindow, string) {
say(why)
return nil, why
}
return func() (*BuildWindow, string) {
if path == "" {
return nil, ""
}
raw, err := os.ReadFile(path)
if err != nil {
return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err))
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
}
setting := ""
if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil {
return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v))
}
w, err := ParseBuildWindow(setting)
if err != nil {
return fault(err.Error())
}
said = ""
return w, ""
}
}
+31 -40
View File
@@ -4,58 +4,49 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
) )
// CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how // CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
// many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names // once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
// a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config) // DefaultCapacities as its default, and the controller lays it into the module's settings file.
// lays the node's value into the module's settings file.
const CapacitySetting = "capacity" const CapacitySetting = "capacity"
// NodeCapacity is the node's build capacity, read from the module's settings file at path at every call, so a change // DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
// takes effect at the next pull. No path, no file value, or a value that is not a whole number of at least 0 gives 0 and // build nothing.
// a value that cannot be read says why: pulling on a setting nobody can read could build on a node that must not. const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
func NodeCapacity(path, node string, logf func(string, ...any)) func() int {
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
// could build on a node that must not.
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
say := func(format string, args ...any) { say := func(format string, args ...any) {
if logf != nil { if logf != nil {
logf(format, args...) logf(format, args...)
} }
} }
return func() int { return func() int {
n, err := ReadCapacity(path) setting := fallback
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
return 0
}
var s map[string]any
if err := json.Unmarshal(raw, &s); err != nil {
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
return 0
}
setting, _ = s[CapacitySetting].(string)
}
caps, err := buildrun.ParseCapacity(setting)
if err != nil { if err != nil {
say("%v, so %s pulls nothing", err, node) say("%v, so %s pulls nothing", fmt.Errorf("the build capacity: %w", err), node)
return 0 return 0
} }
return n return (&buildrun.Places{Capacity: caps}).CapacityOf(node)
} }
} }
// ReadCapacity is the capacity the settings file at path holds, and why it cannot be read when it cannot (then 0).
func ReadCapacity(path string) (int, error) {
if path == "" {
return 0, nil
}
raw, err := os.ReadFile(path)
if err != nil {
return 0, fmt.Errorf("the build capacity cannot be read: %w", err)
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
return 0, fmt.Errorf("the settings file %s is not JSON: %w", path, err)
}
return capacityOf(s[CapacitySetting])
}
// capacityOf is the capacity a config value holds: absent or null is 0; anything but a whole number of at least 0 is
// refused, naming it.
func capacityOf(value json.RawMessage) (int, error) {
if len(value) == 0 || string(value) == "null" {
return 0, nil
}
var n int
if err := json.Unmarshal(value, &n); err != nil || n < 0 {
return 0, fmt.Errorf("the build capacity %s is not a whole number of asks at once, 0 or more", value)
}
return n, nil
}
-141
View File
@@ -1,141 +0,0 @@
package buildstep
import (
"encoding/json"
"fmt"
"math"
"os"
"runtime"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
// 19:41: under the 50 % cap the workstation's mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.<node>` in the pauses
// state that the delivery view's build_usage reads).
// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the
// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default
// (DefaultCoresPerStep).
const CoresPerStepSetting = "cores-per-step"
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
// check on mesh-build-go takes what it is given (three at once took 965 % of the workstation's 1200 % slice, about 4 cores each,
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12
// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's),
// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known.
func Quota() (percent int, from string) {
f := buildcpu.ReadInForce()
if f.Quota > 0 {
path := f.Slice.Path
if path == "" {
path = f.Cgroup
}
return f.Quota, "the cpu.max of " + path
}
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 {
return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU())
}
return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer"
}
// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of
// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one;
// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above
// zero. A coresPerStep of zero or less is taken as 1.
func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity {
if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) {
coresPerStep = 1
}
c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep}
switch {
case configured <= 0:
c.Effective = 0
c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured)
return c
case quotaPercent <= 0:
c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom)
return c
}
c.QuotaCores = float64(quotaPercent) / 100
c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9))
if c.ByCPU < 1 {
c.ByCPU = 1
}
byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU)
if c.ByCPU < configured {
c.Effective = c.ByCPU
c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured)
} else {
c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU)
}
return c
}
// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is
// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said
// once per distinct fault through logf.
func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 {
said := ""
say := func(why string) {
if logf != nil && why != said {
said = why
logf("%s, so a step is taken to need %g cores", why, fallback)
}
}
return func() float64 {
if path == "" {
return fallback
}
raw, err := os.ReadFile(path)
if err != nil {
say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err))
return fallback
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
return fallback
}
v, ok := s[CoresPerStepSetting]
if !ok {
return fallback // not declared yet (a module before its manifest names it): the default, nothing to say
}
n, err := coresOf(v)
if err != nil {
say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err))
return fallback
}
said = ""
return n
}
}
// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero.
func coresOf(v json.RawMessage) (float64, error) {
var n float64
if err := json.Unmarshal(v, &n); err != nil {
var s string
if err := json.Unmarshal(v, &s); err != nil {
return 0, fmt.Errorf("is neither a number nor a string")
}
if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil {
return 0, fmt.Errorf("is not a number")
}
}
if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, fmt.Errorf("is not above zero")
}
return n, nil
}
+22 -119
View File
@@ -3,133 +3,36 @@ package buildstep
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
) )
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls // 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing. // nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And func TestNodeCapacity(t *testing.T) {
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file. t.Errorf("novox = %d by default, want 0", got)
func TestNodeCapacityAndLimits(t *testing.T) { }
// ADR 0567 §12: the capacity is the node's integer, its default 0, and no default names a node. if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
if got := NodeCapacity("", "laptop", nil)(); got != 0 { t.Errorf("shanks = %d by default, want 3", got)
t.Errorf("no settings file = %d, want 0", got)
} }
file := filepath.Join(t.TempDir(), "settings.json") file := filepath.Join(t.TempDir(), "settings.json")
capacity := NodeCapacity(file, "anchor", nil) os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
for value, want := range map[string]int{`{"capacity":1}`: 1, `{"capacity":4}`: 4, `{}`: 0, `{"capacity":null}`: 0, capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
`{"capacity":"anchor=3"}`: 0, `{"capacity":-1}`: 0, `{"capacity":1.5}`: 0} { if got := capacity(); got != 1 {
os.WriteFile(file, []byte(value), 0o600) t.Errorf("shanks = %d from the file, want 1", got)
if got := capacity(); got != want {
t.Errorf("%s = %d, want %d", value, got, want)
}
} }
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), "anchor", nil)(); got != 0 { os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
if got := capacity(); got != 4 {
t.Errorf("shanks = %d after the change, want 4", got)
}
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
t.Errorf("a node not named = %d, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
if got := capacity(); got != 0 {
t.Errorf("an unreadable setting = %d, want 0", got)
}
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
t.Errorf("a missing file = %d, want 0", got) t.Errorf("a missing file = %d, want 0", got)
} }
t.Setenv("MESH_BUILD_CPU_SHARE", "")
if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 {
t.Errorf("without the word: %+v", l)
}
t.Setenv("MESH_BUILD_CPU_SHARE", "75")
if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 {
t.Errorf("from the word: %+v", l)
}
for _, c := range []struct {
name string
configured, quota int
cores float64
effective, byCPU int
}{
{"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2},
{"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3},
{"g14: 8 cores, configured 2", 2, 800, 6, 1, 1},
{"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6},
{"no quota: the configured one", 3, 0, 6, 3, 0},
{"configured 0 pulls nothing", 0, 1200, 6, 0, 0},
{"a quota under one step still builds one", 2, 100, 6, 1, 1},
{"no cores a step is one", 2, 150, 0, 1, 1},
} {
got := EffectiveCapacity(c.configured, c.quota, "test", c.cores)
if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" {
t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU)
}
}
file = filepath.Join(t.TempDir(), "settings.json")
var said []string
cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) })
for _, c := range []struct {
file string
want float64
}{{`{"capacity":2}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
os.WriteFile(file, []byte(c.file), 0o600)
if got := cores(); got != c.want {
t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want)
}
}
if len(said) != 1 {
t.Errorf("a value not above zero is said once, said %d times", len(said))
}
// The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the
// capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour.
// And a node its module proves cannot build (ace without /dev/net/tun, 2026-10-28) judges by 0 at every hour, saying
// why, so its asks go to a node that can.
cet := time.FixedZone("CET", 3600)
day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) }
was := now
defer func() { now = was }()
for _, c := range []struct {
name, setting, node string
at time.Time
effective int
edge string // the window's next edge, HH:MM; "" for no window
bad bool
ineligible string // what the module's Eligible says; "" for eligible
}{
{"the laptop in the office", "18:00-06:00", "laptop", day(10, 0), 0, "18:00", false, ""},
{"the laptop in the evening", "18:00-06:00", "laptop", day(18, 0), 2, "06:00", false, ""},
{"the laptop past midnight", "18:00-06:00", "laptop", day(5, 59), 2, "06:00", false, ""},
{"the laptop at the close", "18:00-06:00", "laptop", day(6, 0), 0, "18:00", false, ""},
{"a day window", "09:00-17:30", "anchor", day(17, 29), 2, "17:30", false, ""},
{"no window", "", "anchor", day(10, 0), 2, "", false, ""},
{"a bad time", "18:00-6", "laptop", day(10, 0), 2, "", true, ""},
{"no span", "18:00-18:00", "laptop", day(10, 0), 2, "", true, ""},
{"the old form naming a node", "laptop=18:00-06:00", "laptop", day(10, 0), 2, "", true, ""},
{"a node without /dev/net/tun", "", "anchor", day(10, 0), 0, "", false, "/dev/net/tun cannot be opened: no such device"},
{"not eligible within its hours", "18:00-06:00", "laptop", day(19, 0), 0, "06:00", false, "/dev/net/tun cannot be opened: no such device"},
} {
w, err := ParseBuildWindow(c.setting)
if (err != nil) != c.bad {
t.Errorf("%s: parse %v", c.name, err)
}
if edge := ""; w != nil {
edge = w.NextEdge(c.at).Format("15:04")
if edge != c.edge {
t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge)
}
} else if c.edge != "" {
t.Errorf("%s: no window, want one", c.name)
}
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600)
now = func() time.Time { return c.at }
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil),
Eligible: func() string { return c.ineligible }}
got := p.CapacityDetail()
outside := c.effective == 0 && c.ineligible == ""
if got.Effective != c.effective || got.OutsideHours != outside || (outside && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective)
}
if got.Ineligible != c.ineligible || (c.ineligible != "" && !strings.HasPrefix(got.Why, "0, not eligible: "+c.ineligible+" (otherwise ")) {
t.Errorf("%s: not eligible is not said: %+v", c.name, got)
}
// A value that cannot be read builds at any hour, loudly: the record names the value and says why.
if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) {
t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got)
}
}
} }
@@ -1,73 +0,0 @@
# The conformance kit's captured runner output
Every file here is the real output of a test runner, never written by hand (issue 1901: a fixture that agrees with the
mistake proves the mistake). The toolchains are the build nodes' own, as `mesh-build-go.build_go_toolchains` and
`mesh-build-node.build_node_toolchains` reported them on 2026-10-26: go1.27.3-X:nodwarf5 on every build node, node
v26.12.1 (npm 12.2.1) on shanks, novox and g14, and typescript 5.9.3 from mesh-build-node's toolchain/package.json.
To capture a case again, make its tiny project in a fresh directory as below and run the command shown; the kit's case
table (conformance.go) says what each failure must say.
## go/ — `go test -json`
- `passfirst.jsonl` and `subtests.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, for issue 1901's thorough fix): in
`/tmp/1901k/go/<case>`, `GOTOOLCHAIN=local GOFLAGS=-mod=mod go test -json -count=1 ./... > ../<case>.jsonl 2>&1`.
- passfirst: module `example.com/passfirst`, `go 1.26`. Package `a` has `TestTheFirstPackagePasses` (empty). Package `b`
has `TestTheSecondPackagePassesFirst` (empty) and `TestTheSecondPackageFails`, which calls
`t.Logf("judged against the seat as it stands")` then `t.Fatalf("the seat has 2 holders, want 1")`: a passing package
and a passing test before the failing one.
- subtests: module `example.com/subtests`; `TestTheTable` runs `t.Run("passes", func(t *testing.T) {})` and
`t.Run("fails", func(t *testing.T) { t.Errorf("got 3 machines, want 4") })`: the parent fails only by its subtest.
- `subpanic.jsonl` (2026-10-26, go1.27.3-X:nodwarf5, review round 1 of issue 1901's fix), the same way: module
`example.com/subpanic`; `TestTable` runs `t.Run("passes", func(t *testing.T) {})` and `t.Run("panics", ...)`, which
writes to a nil map. go test -json files the subtest's panic under the parent `TestTable`.
- `logthenfatal.jsonl`, `panicking`, `timeout`, `deeptimeout`, `buildfail`, `exiting`, `killed`, `passthenfail`,
`mainexit`, `race`, `racemain` were captured for issue 1901's first and third meetings (2026-10-23 and 2026-10-25,
go1.27.3) in mesh-catalog's mesh-build-go (`testdata/captured/go-test-json-*.jsonl`, named there `go-test-json-pkg-<case>`
and `go-test-json-log-then-fatal`) and moved here unchanged. From that README: each is the standard output and error of
`go test -json -count=1 ./...` (the timeouts with `-timeout 2s`; race and racemain with `-race`, CGO_ENABLED=1) in a
throwaway one-file module that fails without a test's own assertion: panicking (a nil map write in TestPanics), timeout
(TestWaitsForever sleeps past the timeout), deeptimeout (two parallel tests and sixty blocked goroutines past the
timeout, so the goroutine dump pushes the panic line out of the last 4 KB), buildfail (a type error in a test file),
exiting (os.Exit(3) in TestExits), killed (TestKilled sends itself SIGKILL), passthenfail (TestMain panics in a
goroutine after m.Run passed), mainexit (TestMain exits 2 after m.Run passed: go test prints "PASS" and then
"FAIL <pkg>"), race (TestRacesOnTheSeat writes a package variable from itself and a goroutine), racemain (TestMain does
so after m.Run passed). logthenfatal is a test `TestThree` in package `x` that logs a line with t.Logf and then fails
with a multi-line t.Fatalf naming three subscriptions.
## node/ — `npm test` running `node --test` under the TAP reporter
Captured 2026-10-26 with node v26.12.1 and npm 12.2.1 (`/usr/bin/node`, `/usr/bin/npm`), in `/tmp/1901k/node/<case>`, the
way mesh-build-node runs a test script:
PATH=/usr/bin:$PATH NODE_OPTIONS="--test-reporter=tap --test-reporter-destination=/tmp/1901k/node/<case>.tap" \
npm test > ../<case>.stdout 2> ../<case>.stderr
`<case>.tap` is the report file, `<case>.console` the stdout followed by the stderr. Each project's package.json is
`{"name":"<case>","private":true,"type":"module","scripts":{"test":"node --test --experimental-strip-types 'test/*.test.ts'"}}`
(the script every TypeScript package of the catalogue and the SDK runs), with these test files:
- two-files: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` holds
`test('passes in the second file first', () => {})` and `test('fails in the second file', ...)`, which logs
`judged against the seat as it stands` and asserts `assert.strictEqual(1 + 1, 3, 'one plus one is not three')`.
- suite: `test/a.test.ts` has `describe('the seat', ...)` with `it('passes inside', () => {})` and `it('fails inside', ...)`
asserting `assert.deepStrictEqual({ holders: 2 }, { holders: 1 })`.
- crash: `test/a.test.ts` holds `test('passes before the crash', () => {})`; `test/b.test.ts` holds
`test('throws after it returned', ...)`, which throws `new Error('the bus closed under the test')` in a `setTimeout` of
1 ms, and `test('waits after the throw', ...)`, which waits 50 ms: node fails the file with an uncaught exception.
- logcrash (review round 1): `test/a.test.ts` holds `test('passes and logs', ...)`, which logs
`retrying after Error: the store was not ready yet`; `test/b.test.ts` is crash's. A passing test's console line
holding "Error" comes before the crash's own.
- timeout: `test/a.test.ts` holds `test('passes before the wait', () => {})` and
`test('waits for an answer that never comes', { timeout: 200 }, async () => { await new Promise(() => {}); })`.
- syntax: `test/a.test.ts` holds `test('passes in the first file', () => {})`; `test/b.test.ts` is
`import test from 'node:test';` and `test('never loads', () => {` (cut off): the file does not load.
## tsc/ — the type-check
`typeerror.console` (2026-10-26, typescript 5.9.3 under node v26.12.1): in `/tmp/1901k/tsc/typeerror`, `ok.ts` is
`export const holders: number = 1;` and `seat.ts` is `import { holders } from './ok.js'; export const name: string = holders;`;
the command is mesh-build-node's type-check line without a tsconfig.json:
node <typescript>/bin/tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . ok.ts seat.ts
and the file is its stdout followed by its stderr.
@@ -1,6 +0,0 @@
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"# example.com/buildfail [example.com/buildfail.test]\n"}
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-output","Output":"./p_test.go:5:40: cannot use \"seat\" (untyped string constant) as int value in variable declaration\n"}
{"ImportPath":"example.com/buildfail [example.com/buildfail.test]","Action":"build-fail"}
{"Time":"2026-10-25T15:34:33.261137737+01:00","Action":"start","Package":"example.com/buildfail"}
{"Time":"2026-10-25T15:34:33.261255019+01:00","Action":"output","Package":"example.com/buildfail","Output":"FAIL\texample.com/buildfail [build failed]\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.261268099+01:00","Action":"fail","Package":"example.com/buildfail","Elapsed":0,"FailedBuild":"example.com/buildfail [example.com/buildfail.test]"}
@@ -1,417 +0,0 @@
{"Time":"2026-10-25T15:35:18.991630993+01:00","Action":"start","Package":"example.com/deeptimeout"}
{"Time":"2026-10-25T15:35:18.993895188+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
{"Time":"2026-10-25T15:35:18.993963909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== RUN TestHoldsTheSeat\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:18.99399484+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== PAUSE TestHoldsTheSeat\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:18.9939982+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
{"Time":"2026-10-25T15:35:18.99400173+01:00","Action":"run","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
{"Time":"2026-10-25T15:35:18.99400456+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== RUN TestWaitsForTheBus\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:18.99400838+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== PAUSE TestWaitsForTheBus\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:18.99401133+01:00","Action":"pause","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
{"Time":"2026-10-25T15:35:18.99401441+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat"}
{"Time":"2026-10-25T15:35:18.99401704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":"=== CONT TestHoldsTheSeat\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:18.994074141+01:00","Action":"cont","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus"}
{"Time":"2026-10-25T15:35:18.994102612+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"=== CONT TestWaitsForTheBus\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:21.00935195+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"panic: test timed out after 2s\n"}
{"Time":"2026-10-25T15:35:21.00938383+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\trunning tests:\n"}
{"Time":"2026-10-25T15:35:21.009394391+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestHoldsTheSeat (2s)\n"}
{"Time":"2026-10-25T15:35:21.009398021+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t\tTestWaitsForTheBus (2s)\n"}
{"Time":"2026-10-25T15:35:21.009401801+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009406481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 81 [running]:\n"}
{"Time":"2026-10-25T15:35:21.009409911+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).startAlarm.func1()\n"}
{"Time":"2026-10-25T15:35:21.009413361+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"}
{"Time":"2026-10-25T15:35:21.009418341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by time.goFunc\n"}
{"Time":"2026-10-25T15:35:21.009421781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"}
{"Time":"2026-10-25T15:35:21.009424991+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009428341+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 1 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009431721+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner.func1()\n"}
{"Time":"2026-10-25T15:35:21.009441811+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2144 +0x425\n"}
{"Time":"2026-10-25T15:35:21.009445511+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702008, 0x282e9646bc8)\n"}
{"Time":"2026-10-25T15:35:21.009449641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2201 +0x123\n"}
{"Time":"2026-10-25T15:35:21.009453471+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.runTests({0x55a45d, 0x17}, {0x55a45d, 0x17}, 0x282e95940f0, {0x6f19f0, 0x2, 0x2}, {0xc2afa2ca3b3c0c8d, 0x7737f168, ...})\n"}
{"Time":"2026-10-25T15:35:21.009462092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"}
{"Time":"2026-10-25T15:35:21.009467222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.(*M).Run(0x282e96581e0)\n"}
{"Time":"2026-10-25T15:35:21.009470662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"}
{"Time":"2026-10-25T15:35:21.009473692+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"main.main()\n"}
{"Time":"2026-10-25T15:35:21.009476942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t_testmain.go:48 +0x9b\n"}
{"Time":"2026-10-25T15:35:21.009480092+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009483242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 7 [sleep]:\n"}
{"Time":"2026-10-25T15:35:21.009486502+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"}
{"Time":"2026-10-25T15:35:21.009490682+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
{"Time":"2026-10-25T15:35:21.009493812+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat(0x282e9702248?)\n"}
{"Time":"2026-10-25T15:35:21.009498842+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:14 +0x9e\n"}
{"Time":"2026-10-25T15:35:21.009502242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702248, 0x6d5e78)\n"}
{"Time":"2026-10-25T15:35:21.009505742+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-25T15:35:21.009509292+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"}
{"Time":"2026-10-25T15:35:21.009512752+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
{"Time":"2026-10-25T15:35:21.009515792+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009518623+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 8 [sleep]:\n"}
{"Time":"2026-10-25T15:35:21.009521303+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"time.Sleep(0xdf8475800)\n"}
{"Time":"2026-10-25T15:35:21.009524423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
{"Time":"2026-10-25T15:35:21.009531403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestWaitsForTheBus(0x282e9702488?)\n"}
{"Time":"2026-10-25T15:35:21.009534943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:19 +0x25\n"}
{"Time":"2026-10-25T15:35:21.009538403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"testing.tRunner(0x282e9702488, 0x6d5e80)\n"}
{"Time":"2026-10-25T15:35:21.009541953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-25T15:35:21.009545233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by testing.(*T).Run in goroutine 1\n"}
{"Time":"2026-10-25T15:35:21.009548323+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
{"Time":"2026-10-25T15:35:21.009551343+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009554263+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 9 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009557523+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009561063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009564513+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009571113+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009574233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009577433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 10 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009580823+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009584174+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009587504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009590974+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009594004+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009597184+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 11 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009600394+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009603874+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009610954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009614714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009618104+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009621364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 12 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009624704+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009628324+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009631954+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009635404+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009638734+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009641904+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 13 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009645325+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009648645+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009652065+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009655625+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009658885+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009663385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 14 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009666735+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009670235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009676385+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009680205+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009683315+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009686475+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 15 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009689785+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009697235+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009700795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009704985+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009708085+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009711346+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 16 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009714586+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009717946+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009721676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009725626+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009728866+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009732106+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 17 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009735376+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009738956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009742326+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009746436+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009750666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009754126+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 18 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009758286+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009761846+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009765776+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009769816+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009773167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009780507+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 19 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009784207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009788007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009791627+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009796347+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009799427+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009802937+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 20 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009806667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009809987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009813827+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009818087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009821397+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009824227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 21 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009827607+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009831307+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009835167+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009839528+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009842728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009845888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 22 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009849168+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009852548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009855958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009863648+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009866938+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009870088+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 23 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009879828+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009883728+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009887608+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009891548+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009895238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009898748+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 24 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009902349+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009907109+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009911519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009915609+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009918939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009922329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 25 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009928329+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009931909+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.009935489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.009939429+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.009942699+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.009946019+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 26 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.009949559+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.009953489+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.00996244+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.00996647+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.00996987+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.00997314+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 27 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.00997664+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.00998035+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.00998418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.00998793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.00999131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.00999534+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 28 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.00999894+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.01000268+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.0100087+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.01001258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.01001605+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.01002007+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 29 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.01002393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010027531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010031351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010035521+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010038781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010042281+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 30 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010046091+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010054261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010057881+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010061781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010064931+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010068261+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 31 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010072151+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010075981+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010079841+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010098802+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010103202+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010106382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 32 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010109852+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010113242+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010116952+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010120672+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010124112+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010127162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 33 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010130662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010134442+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010138142+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010141972+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010145362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010152262+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 34 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010156353+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010159913+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010163533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010167403+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010170533+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010173953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 35 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010177453+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010181033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010184693+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010188583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010191753+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010195033+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 36 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010198603+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010202393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010206123+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010210583+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010213503+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010216914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 37 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010220134+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010223644+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010227154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010235154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010238354+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010241754+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 38 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010270284+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010275144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010278714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010282825+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010285795+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010288935+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 39 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010292405+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010295915+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010299545+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010311835+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010315565+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010318975+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 40 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010322655+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010326225+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010329815+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010333745+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010337095+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010340515+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 41 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010344066+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010347706+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010355596+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010359046+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010362036+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010365076+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 42 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010367956+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010370906+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010375366+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010378386+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010381006+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010383666+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 43 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010386676+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010389786+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010392636+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010395746+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010398466+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010401166+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 44 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010404226+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010407186+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010410117+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010413237+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010416157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010418897+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 45 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010423157+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010430107+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010433197+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010451207+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010454217+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010456967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 46 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010459877+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010463967+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010467067+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010470597+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010473418+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010476108+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 47 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010478998+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010481958+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010484978+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010488258+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010490888+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010493628+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 48 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010496588+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010499498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010502498+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010505768+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010508558+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010514148+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 49 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010517178+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010520238+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010523478+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010526818+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010529718+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010532508+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 50 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010535639+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010538749+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010541939+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010546689+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010549869+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010552989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 51 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010556299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010560079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010563479+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010567189+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010570299+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010573079+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 52 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010576319+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010579599+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010583099+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010590519+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010594029+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010596989+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 53 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.01059986+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.01060433+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.01060789+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.01061136+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.01061393+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.01061667+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 54 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.01061984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.0106227+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.01062566+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.01062876+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.01063131+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.01063428+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 55 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.01063719+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.01063997+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.01064278+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.01064582+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.01064836+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.01065114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 56 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.01065437+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.01065724+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010671481+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010675671+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010678431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010691171+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 57 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010694531+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010697641+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010700781+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010704001+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010706611+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010709421+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 58 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010712331+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010715351+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010718431+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010721691+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010724371+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010727362+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 59 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010730592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010733662+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010736712+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010740162+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010742922+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010745772+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 60 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010752222+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010755402+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010758542+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010761822+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010764592+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010767992+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 61 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010771062+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010774282+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010779382+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010782942+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010785722+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010788573+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 62 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010791893+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010794943+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010798083+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010801683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010804293+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010807283+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 63 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010810443+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010813553+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010816793+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010820233+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010823063+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010829493+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 64 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010832683+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010835953+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010839223+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010842463+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010845333+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010848423+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 65 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010851663+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010854964+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010858364+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010861714+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010864914+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010867984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 66 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010870944+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010874114+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010877464+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010880504+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010883144+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010886194+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 67 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010889154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010892234+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010895524+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010902154+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.010904984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\n"}
{"Time":"2026-10-25T15:35:21.010907984+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"goroutine 68 [chan receive]:\n"}
{"Time":"2026-10-25T15:35:21.010911064+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"example.com/deeptimeout.TestHoldsTheSeat.func1()\n"}
{"Time":"2026-10-25T15:35:21.010914074+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x19\n"}
{"Time":"2026-10-25T15:35:21.010917365+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"created by example.com/deeptimeout.TestHoldsTheSeat in goroutine 7\n"}
{"Time":"2026-10-25T15:35:21.010920505+01:00","Action":"output","Package":"example.com/deeptimeout","Test":"TestWaitsForTheBus","Output":"\t/tmp/fix-1901b/deeptimeout/p_test.go:12 +0x37\n"}
{"Time":"2026-10-25T15:35:21.013209581+01:00","Action":"output","Package":"example.com/deeptimeout","Output":"FAIL\texample.com/deeptimeout\t2.021s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:35:21.013258851+01:00","Action":"fail","Package":"example.com/deeptimeout","Elapsed":2.022}
@@ -1,10 +0,0 @@
{"Time":"2026-10-25T15:34:33.768742837+01:00","Action":"start","Package":"example.com/exiting"}
{"Time":"2026-10-25T15:34:33.773822287+01:00","Action":"run","Package":"example.com/exiting","Test":"TestFine"}
{"Time":"2026-10-25T15:34:33.773896608+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.773940319+01:00","Action":"output","Package":"example.com/exiting","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.773945359+01:00","Action":"pass","Package":"example.com/exiting","Test":"TestFine","Elapsed":0}
{"Time":"2026-10-25T15:34:33.773952529+01:00","Action":"run","Package":"example.com/exiting","Test":"TestExits"}
{"Time":"2026-10-25T15:34:33.773956439+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":"=== RUN TestExits\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.773959829+01:00","Action":"output","Package":"example.com/exiting","Test":"TestExits","Output":" p_test.go:11: closing the store\n"}
{"Time":"2026-10-25T15:34:33.775158538+01:00","Action":"output","Package":"example.com/exiting","Output":"FAIL\texample.com/exiting\t0.006s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.775186509+01:00","Action":"fail","Package":"example.com/exiting","Elapsed":0.006}
@@ -1,7 +0,0 @@
{"Time":"2026-10-25T15:34:34.328258934+01:00","Action":"start","Package":"example.com/killed"}
{"Time":"2026-10-25T15:34:34.331465435+01:00","Action":"run","Package":"example.com/killed","Test":"TestKilled"}
{"Time":"2026-10-25T15:34:34.331699248+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"=== RUN TestKilled\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.331853111+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":" p_test.go:10: the kernel is about to kill this\n"}
{"Time":"2026-10-25T15:34:34.334563213+01:00","Action":"output","Package":"example.com/killed","Test":"TestKilled","Output":"signal: killed\n"}
{"Time":"2026-10-25T15:34:34.334611114+01:00","Action":"output","Package":"example.com/killed","Output":"FAIL\texample.com/killed\t0.006s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.334627134+01:00","Action":"fail","Package":"example.com/killed","Elapsed":0.006}
@@ -1,13 +0,0 @@
{"Action":"start","Package":"x"}
{"Action":"run","Package":"x","Test":"TestThree"}
{"Action":"output","Package":"x","Test":"TestThree","Output":"=== RUN TestThree\n","OutputType":"frame"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:4: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:5: 3 subscription(s) derive to a subject no emitter owns:\n","OutputType":"error"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a.b.c\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" d.e.f\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" g.h.i\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":"--- FAIL: TestThree (0.00s)\n","OutputType":"frame"}
{"Action":"fail","Package":"x","Test":"TestThree","Elapsed":0}
{"Action":"output","Package":"x","Output":"FAIL\n","OutputType":"frame"}
{"Action":"output","Package":"x","Output":"FAIL\tx\t0.002s\n","OutputType":"frame"}
{"Action":"fail","Package":"x","Elapsed":0.002}
@@ -1,8 +0,0 @@
{"Time":"2026-10-25T15:34:35.274953775+01:00","Action":"start","Package":"example.com/mainexit"}
{"Time":"2026-10-25T15:34:35.278129565+01:00","Action":"run","Package":"example.com/mainexit","Test":"TestFine"}
{"Time":"2026-10-25T15:34:35.278191516+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:35.278341979+01:00","Action":"output","Package":"example.com/mainexit","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:35.278351739+01:00","Action":"pass","Package":"example.com/mainexit","Test":"TestFine","Elapsed":0}
{"Time":"2026-10-25T15:34:35.278359039+01:00","Action":"output","Package":"example.com/mainexit","Output":"PASS\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:35.279516487+01:00","Action":"output","Package":"example.com/mainexit","Output":"FAIL\texample.com/mainexit\t0.004s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:35.279539298+01:00","Action":"fail","Package":"example.com/mainexit","Elapsed":0.005}
@@ -1,27 +0,0 @@
{"Time":"2026-10-25T15:34:33.031091436+01:00","Action":"start","Package":"example.com/panicking"}
{"Time":"2026-10-25T15:34:33.036353279+01:00","Action":"run","Package":"example.com/panicking","Test":"TestBefore"}
{"Time":"2026-10-25T15:34:33.036441781+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.036568163+01:00","Action":"output","Package":"example.com/panicking","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.036609863+01:00","Action":"pass","Package":"example.com/panicking","Test":"TestBefore","Elapsed":0}
{"Time":"2026-10-25T15:34:33.036641304+01:00","Action":"run","Package":"example.com/panicking","Test":"TestPanics"}
{"Time":"2026-10-25T15:34:33.036645914+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"=== RUN TestPanics\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.036698995+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":" p_test.go:8: about to look up the seat\n"}
{"Time":"2026-10-25T15:34:33.036773996+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"--- FAIL: TestPanics (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.03897418+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"}
{"Time":"2026-10-25T15:34:33.040332172+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\n"}
{"Time":"2026-10-25T15:34:33.040414493+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"goroutine 8 [running]:\n"}
{"Time":"2026-10-25T15:34:33.040521475+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1.2({0x6b8120, 0x6eff80})\n"}
{"Time":"2026-10-25T15:34:33.040601826+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"}
{"Time":"2026-10-25T15:34:33.040641947+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner.func1()\n"}
{"Time":"2026-10-25T15:34:33.040720648+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"}
{"Time":"2026-10-25T15:34:33.04083286+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"panic({0x6b8120?, 0x6eff80?})\n"}
{"Time":"2026-10-25T15:34:33.040912921+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"}
{"Time":"2026-10-25T15:34:33.040981652+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"example.com/panicking.TestPanics(0x228226cd0488?)\n"}
{"Time":"2026-10-25T15:34:33.041059903+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/tmp/fix-1901b/panicking/p_test.go:10 +0x53\n"}
{"Time":"2026-10-25T15:34:33.041132704+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"testing.tRunner(0x228226cd0488, 0x6d5bc8)\n"}
{"Time":"2026-10-25T15:34:33.041215586+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-25T15:34:33.041221966+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"created by testing.(*T).Run in goroutine 1\n"}
{"Time":"2026-10-25T15:34:33.041225556+01:00","Action":"output","Package":"example.com/panicking","Test":"TestPanics","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
{"Time":"2026-10-25T15:34:33.041984088+01:00","Action":"fail","Package":"example.com/panicking","Test":"TestPanics","Elapsed":0}
{"Time":"2026-10-25T15:34:33.041996638+01:00","Action":"output","Package":"example.com/panicking","Output":"FAIL\texample.com/panicking\t0.010s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:33.042044079+01:00","Action":"fail","Package":"example.com/panicking","Elapsed":0.011}
@@ -1,22 +0,0 @@
{"Time":"2026-10-26T19:40:29.574068887+01:00","Action":"start","Package":"example.com/passfirst/a"}
{"Time":"2026-10-26T19:40:29.574347185+01:00","Action":"start","Package":"example.com/passfirst/b"}
{"Time":"2026-10-26T19:40:29.576503518+01:00","Action":"run","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses"}
{"Time":"2026-10-26T19:40:29.576557657+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"=== RUN TestTheFirstPackagePasses\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576589367+01:00","Action":"output","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Output":"--- PASS: TestTheFirstPackagePasses (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576594637+01:00","Action":"pass","Package":"example.com/passfirst/a","Test":"TestTheFirstPackagePasses","Elapsed":0}
{"Time":"2026-10-26T19:40:29.576602487+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"PASS\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576614477+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst"}
{"Time":"2026-10-26T19:40:29.576626897+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"=== RUN TestTheSecondPackagePassesFirst\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576631187+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Output":"--- PASS: TestTheSecondPackagePassesFirst (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576635567+01:00","Action":"pass","Package":"example.com/passfirst/b","Test":"TestTheSecondPackagePassesFirst","Elapsed":0}
{"Time":"2026-10-26T19:40:29.576638427+01:00","Action":"run","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails"}
{"Time":"2026-10-26T19:40:29.576640647+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"=== RUN TestTheSecondPackageFails\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576643477+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:8: judged against the seat as it stands\n"}
{"Time":"2026-10-26T19:40:29.576646497+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":" b_test.go:9: the seat has 2 holders, want 1\n","OutputType":"error"}
{"Time":"2026-10-26T19:40:29.576650087+01:00","Action":"output","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Output":"--- FAIL: TestTheSecondPackageFails (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.576652797+01:00","Action":"fail","Package":"example.com/passfirst/b","Test":"TestTheSecondPackageFails","Elapsed":0}
{"Time":"2026-10-26T19:40:29.576655177+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.577365541+01:00","Action":"output","Package":"example.com/passfirst/a","Output":"ok \texample.com/passfirst/a\t0.003s\n"}
{"Time":"2026-10-26T19:40:29.577382561+01:00","Action":"pass","Package":"example.com/passfirst/a","Elapsed":0.003}
{"Time":"2026-10-26T19:40:29.577380641+01:00","Action":"output","Package":"example.com/passfirst/b","Output":"FAIL\texample.com/passfirst/b\t0.003s\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.5774131+01:00","Action":"fail","Package":"example.com/passfirst/b","Elapsed":0.003}
@@ -1,15 +0,0 @@
{"Time":"2026-10-25T15:34:34.919624192+01:00","Action":"start","Package":"example.com/passthenfail"}
{"Time":"2026-10-25T15:34:34.922861224+01:00","Action":"run","Package":"example.com/passthenfail","Test":"TestFine"}
{"Time":"2026-10-25T15:34:34.922932965+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.922969205+01:00","Action":"output","Package":"example.com/passthenfail","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.922979956+01:00","Action":"pass","Package":"example.com/passthenfail","Test":"TestFine","Elapsed":0}
{"Time":"2026-10-25T15:34:34.922989416+01:00","Action":"output","Package":"example.com/passthenfail","Output":"PASS\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.925022528+01:00","Action":"output","Package":"example.com/passthenfail","Output":"panic: the bus closed after the tests: nats: connection closed\n"}
{"Time":"2026-10-25T15:34:34.925043388+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\n"}
{"Time":"2026-10-25T15:34:34.925046848+01:00","Action":"output","Package":"example.com/passthenfail","Output":"goroutine 20 [running]:\n"}
{"Time":"2026-10-25T15:34:34.925051338+01:00","Action":"output","Package":"example.com/passthenfail","Output":"example.com/passthenfail.TestMain.func1()\n"}
{"Time":"2026-10-25T15:34:34.925055878+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"}
{"Time":"2026-10-25T15:34:34.925061428+01:00","Action":"output","Package":"example.com/passthenfail","Output":"created by example.com/passthenfail.TestMain in goroutine 1\n"}
{"Time":"2026-10-25T15:34:34.925065028+01:00","Action":"output","Package":"example.com/passthenfail","Output":"\t/tmp/fix-1901b/passthenfail/p_test.go:13 +0x25\n"}
{"Time":"2026-10-25T15:34:34.925390203+01:00","Action":"output","Package":"example.com/passthenfail","Output":"FAIL\texample.com/passthenfail\t0.005s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:34.925402904+01:00","Action":"fail","Package":"example.com/passthenfail","Elapsed":0.006}
@@ -1,53 +0,0 @@
{"Time":"2026-10-25T15:50:42.908557309+01:00","Action":"start","Package":"example.com/race"}
{"Time":"2026-10-25T15:50:42.924298679+01:00","Action":"run","Package":"example.com/race","Test":"TestBefore"}
{"Time":"2026-10-25T15:50:42.92438195+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"=== RUN TestBefore\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.924648842+01:00","Action":"output","Package":"example.com/race","Test":"TestBefore","Output":"--- PASS: TestBefore (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.924685133+01:00","Action":"pass","Package":"example.com/race","Test":"TestBefore","Elapsed":0}
{"Time":"2026-10-25T15:50:42.924728573+01:00","Action":"run","Package":"example.com/race","Test":"TestRacesOnTheSeat"}
{"Time":"2026-10-25T15:50:42.924733573+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"=== RUN TestRacesOnTheSeat\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.92637298+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"}
{"Time":"2026-10-25T15:50:42.92641567+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"WARNING: DATA RACE\n"}
{"Time":"2026-10-25T15:50:42.926444061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Write at 0x000000833468 by goroutine 10:\n"}
{"Time":"2026-10-25T15:50:42.926462621+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat.func1()\n"}
{"Time":"2026-10-25T15:50:42.926467121+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"}
{"Time":"2026-10-25T15:50:42.926483061+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
{"Time":"2026-10-25T15:50:42.926509741+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Previous write at 0x000000833468 by goroutine 9:\n"}
{"Time":"2026-10-25T15:50:42.926530142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"}
{"Time":"2026-10-25T15:50:42.926534952+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:14 +0x30\n"}
{"Time":"2026-10-25T15:50:42.926550462+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
{"Time":"2026-10-25T15:50:42.926554612+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
{"Time":"2026-10-25T15:50:42.926571142+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"}
{"Time":"2026-10-25T15:50:42.926575812+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"}
{"Time":"2026-10-25T15:50:42.926592202+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
{"Time":"2026-10-25T15:50:42.926607792+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 10 (running) created at:\n"}
{"Time":"2026-10-25T15:50:42.926624292+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" example.com/race.TestRacesOnTheSeat()\n"}
{"Time":"2026-10-25T15:50:42.926628923+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /tmp/fix-1901b/race/p_test.go:13 +0x24\n"}
{"Time":"2026-10-25T15:50:42.926643853+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
{"Time":"2026-10-25T15:50:42.926648103+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
{"Time":"2026-10-25T15:50:42.926674843+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run.gowrap1()\n"}
{"Time":"2026-10-25T15:50:42.926680673+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0x38\n"}
{"Time":"2026-10-25T15:50:42.926695433+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"\n"}
{"Time":"2026-10-25T15:50:42.926711063+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"Goroutine 9 (running) created at:\n"}
{"Time":"2026-10-25T15:50:42.926726614+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*T).Run()\n"}
{"Time":"2026-10-25T15:50:42.926730814+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2260 +0xb12\n"}
{"Time":"2026-10-25T15:50:42.926746484+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests.func1()\n"}
{"Time":"2026-10-25T15:50:42.926750764+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2744 +0x84\n"}
{"Time":"2026-10-25T15:50:42.926765354+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.tRunner()\n"}
{"Time":"2026-10-25T15:50:42.926769844+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2195 +0x21c\n"}
{"Time":"2026-10-25T15:50:42.926791794+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.runTests()\n"}
{"Time":"2026-10-25T15:50:42.926796704+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2742 +0x9e9\n"}
{"Time":"2026-10-25T15:50:42.926811544+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.(*M).Run()\n"}
{"Time":"2026-10-25T15:50:42.926816694+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" /usr/lib/go/src/testing/testing.go:2602 +0xf44\n"}
{"Time":"2026-10-25T15:50:42.926831695+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" main.main()\n"}
{"Time":"2026-10-25T15:50:42.926835935+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" _testmain.go:50 +0x164\n"}
{"Time":"2026-10-25T15:50:42.926850905+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"==================\n"}
{"Time":"2026-10-25T15:50:42.935320241+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":" testing.go:1867: race detected during execution of test\n","OutputType":"error"}
{"Time":"2026-10-25T15:50:42.935520113+01:00","Action":"output","Package":"example.com/race","Test":"TestRacesOnTheSeat","Output":"--- FAIL: TestRacesOnTheSeat (0.01s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.935592973+01:00","Action":"fail","Package":"example.com/race","Test":"TestRacesOnTheSeat","Elapsed":0.01}
{"Time":"2026-10-25T15:50:42.935722225+01:00","Action":"run","Package":"example.com/race","Test":"TestAfter"}
{"Time":"2026-10-25T15:50:42.935728845+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"=== RUN TestAfter\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.935995928+01:00","Action":"output","Package":"example.com/race","Test":"TestAfter","Output":"--- PASS: TestAfter (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.936006328+01:00","Action":"pass","Package":"example.com/race","Test":"TestAfter","Elapsed":0}
{"Time":"2026-10-25T15:50:42.936009978+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.937730745+01:00","Action":"output","Package":"example.com/race","Output":"FAIL\texample.com/race\t0.028s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:42.937802066+01:00","Action":"fail","Package":"example.com/race","Elapsed":0.029}
@@ -1,27 +0,0 @@
{"Time":"2026-10-25T15:50:43.551701745+01:00","Action":"start","Package":"example.com/racemain"}
{"Time":"2026-10-25T15:50:43.570709708+01:00","Action":"run","Package":"example.com/racemain","Test":"TestFine"}
{"Time":"2026-10-25T15:50:43.570806059+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"=== RUN TestFine\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:43.57085525+01:00","Action":"output","Package":"example.com/racemain","Test":"TestFine","Output":"--- PASS: TestFine (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:43.57086149+01:00","Action":"pass","Package":"example.com/racemain","Test":"TestFine","Elapsed":0}
{"Time":"2026-10-25T15:50:43.57086974+01:00","Action":"output","Package":"example.com/racemain","Output":"PASS\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:43.572155623+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"}
{"Time":"2026-10-25T15:50:43.572168083+01:00","Action":"output","Package":"example.com/racemain","Output":"WARNING: DATA RACE\n"}
{"Time":"2026-10-25T15:50:43.572171933+01:00","Action":"output","Package":"example.com/racemain","Output":"Write at 0x000000836428 by goroutine 9:\n"}
{"Time":"2026-10-25T15:50:43.572176703+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain.func1()\n"}
{"Time":"2026-10-25T15:50:43.572180053+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x24\n"}
{"Time":"2026-10-25T15:50:43.572184923+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"}
{"Time":"2026-10-25T15:50:43.572189613+01:00","Action":"output","Package":"example.com/racemain","Output":"Previous write at 0x000000836428 by main goroutine:\n"}
{"Time":"2026-10-25T15:50:43.572192993+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"}
{"Time":"2026-10-25T15:50:43.572198573+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:16 +0x44\n"}
{"Time":"2026-10-25T15:50:43.572202433+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"}
{"Time":"2026-10-25T15:50:43.572205623+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"}
{"Time":"2026-10-25T15:50:43.572208443+01:00","Action":"output","Package":"example.com/racemain","Output":"\n"}
{"Time":"2026-10-25T15:50:43.572211573+01:00","Action":"output","Package":"example.com/racemain","Output":"Goroutine 9 (running) created at:\n"}
{"Time":"2026-10-25T15:50:43.572214803+01:00","Action":"output","Package":"example.com/racemain","Output":" example.com/racemain.TestMain()\n"}
{"Time":"2026-10-25T15:50:43.572218043+01:00","Action":"output","Package":"example.com/racemain","Output":" /tmp/fix-1901b/racemain/p_test.go:15 +0x37\n"}
{"Time":"2026-10-25T15:50:43.572225414+01:00","Action":"output","Package":"example.com/racemain","Output":" main.main()\n"}
{"Time":"2026-10-25T15:50:43.572228654+01:00","Action":"output","Package":"example.com/racemain","Output":" _testmain.go:48 +0x171\n"}
{"Time":"2026-10-25T15:50:43.572231664+01:00","Action":"output","Package":"example.com/racemain","Output":"==================\n"}
{"Time":"2026-10-25T15:50:44.581262249+01:00","Action":"output","Package":"example.com/racemain","Output":"Found 1 data race(s)\n"}
{"Time":"2026-10-25T15:50:44.584693204+01:00","Action":"output","Package":"example.com/racemain","Output":"FAIL\texample.com/racemain\t1.031s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:50:44.584737425+01:00","Action":"fail","Package":"example.com/racemain","Elapsed":1.033}
@@ -1,30 +0,0 @@
{"Time":"2026-10-26T20:15:37.791161045+01:00","Action":"start","Package":"example.com/subpanic"}
{"Time":"2026-10-26T20:15:37.793205666+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable"}
{"Time":"2026-10-26T20:15:37.793257158+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"=== RUN TestTable\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.793288149+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/passes"}
{"Time":"2026-10-26T20:15:37.793290669+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"=== RUN TestTable/passes\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.793298009+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/passes","Output":"--- PASS: TestTable/passes (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.793311679+01:00","Action":"pass","Package":"example.com/subpanic","Test":"TestTable/passes","Elapsed":0}
{"Time":"2026-10-26T20:15:37.79331977+01:00","Action":"run","Package":"example.com/subpanic","Test":"TestTable/panics"}
{"Time":"2026-10-26T20:15:37.79332377+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"=== RUN TestTable/panics\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.79332773+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable/panics","Output":"--- FAIL: TestTable/panics (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.79333111+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable/panics","Elapsed":0}
{"Time":"2026-10-26T20:15:37.79333383+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"--- FAIL: TestTable (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.795322529+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic: assignment to entry in nil map [recovered, repanicked]\n"}
{"Time":"2026-10-26T20:15:37.795331489+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\n"}
{"Time":"2026-10-26T20:15:37.795340579+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"goroutine 9 [running]:\n"}
{"Time":"2026-10-26T20:15:37.79534387+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1.2({0x6b7010, 0x6eef80})\n"}
{"Time":"2026-10-26T20:15:37.79534657+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2125 +0x232\n"}
{"Time":"2026-10-26T20:15:37.79534907+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner.func1()\n"}
{"Time":"2026-10-26T20:15:37.79535172+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2128 +0x329\n"}
{"Time":"2026-10-26T20:15:37.79535625+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"panic({0x6b7010?, 0x6eef80?})\n"}
{"Time":"2026-10-26T20:15:37.79535879+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/runtime/panic.go:859 +0x125\n"}
{"Time":"2026-10-26T20:15:37.79536129+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"example.com/subpanic.TestTable.func2(0x3b39436666c8?)\n"}
{"Time":"2026-10-26T20:15:37.79536452+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/tmp/1901k/go/subpanic/p_test.go:7 +0x28\n"}
{"Time":"2026-10-26T20:15:37.79536782+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"testing.tRunner(0x3b39436666c8, 0x6d4b58)\n"}
{"Time":"2026-10-26T20:15:37.7953704+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-26T20:15:37.79537289+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"created by testing.(*T).Run in goroutine 7\n"}
{"Time":"2026-10-26T20:15:37.795381681+01:00","Action":"output","Package":"example.com/subpanic","Test":"TestTable","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
{"Time":"2026-10-26T20:15:37.795731441+01:00","Action":"fail","Package":"example.com/subpanic","Test":"TestTable","Elapsed":0}
{"Time":"2026-10-26T20:15:37.795759332+01:00","Action":"output","Package":"example.com/subpanic","Output":"FAIL\texample.com/subpanic\t0.004s\n","OutputType":"frame"}
{"Time":"2026-10-26T20:15:37.795783503+01:00","Action":"fail","Package":"example.com/subpanic","Elapsed":0.005}
@@ -1,17 +0,0 @@
{"Time":"2026-10-26T19:40:29.768101399+01:00","Action":"start","Package":"example.com/subtests"}
{"Time":"2026-10-26T19:40:29.770109733+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable"}
{"Time":"2026-10-26T19:40:29.770152272+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"=== RUN TestTheTable\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770189372+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/passes"}
{"Time":"2026-10-26T19:40:29.770191882+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"=== RUN TestTheTable/passes\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770197442+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/passes","Output":"--- PASS: TestTheTable/passes (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770204352+01:00","Action":"pass","Package":"example.com/subtests","Test":"TestTheTable/passes","Elapsed":0}
{"Time":"2026-10-26T19:40:29.770209622+01:00","Action":"run","Package":"example.com/subtests","Test":"TestTheTable/fails"}
{"Time":"2026-10-26T19:40:29.770211832+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"=== RUN TestTheTable/fails\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770214722+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":" s_test.go:7: got 3 machines, want 4\n","OutputType":"error"}
{"Time":"2026-10-26T19:40:29.770218032+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable/fails","Output":"--- FAIL: TestTheTable/fails (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770220732+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable/fails","Elapsed":0}
{"Time":"2026-10-26T19:40:29.770223522+01:00","Action":"output","Package":"example.com/subtests","Test":"TestTheTable","Output":"--- FAIL: TestTheTable (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770226292+01:00","Action":"fail","Package":"example.com/subtests","Test":"TestTheTable","Elapsed":0}
{"Time":"2026-10-26T19:40:29.770228582+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770561979+01:00","Action":"output","Package":"example.com/subtests","Output":"FAIL\texample.com/subtests\t0.002s\n","OutputType":"frame"}
{"Time":"2026-10-26T19:40:29.770581629+01:00","Action":"fail","Package":"example.com/subtests","Elapsed":0.002}
@@ -1,43 +0,0 @@
{"Time":"2026-10-25T15:34:36.049580618+01:00","Action":"start","Package":"example.com/timeout"}
{"Time":"2026-10-25T15:34:36.053250986+01:00","Action":"run","Package":"example.com/timeout","Test":"TestQuick"}
{"Time":"2026-10-25T15:34:36.053354018+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"=== RUN TestQuick\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:36.053459789+01:00","Action":"output","Package":"example.com/timeout","Test":"TestQuick","Output":"--- PASS: TestQuick (0.00s)\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:36.05348499+01:00","Action":"pass","Package":"example.com/timeout","Test":"TestQuick","Elapsed":0}
{"Time":"2026-10-25T15:34:36.0535114+01:00","Action":"run","Package":"example.com/timeout","Test":"TestWaitsForever"}
{"Time":"2026-10-25T15:34:36.05351549+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"=== RUN TestWaitsForever\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:36.053567451+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":" p_test.go:11: waiting for the bus\n"}
{"Time":"2026-10-25T15:34:38.064693307+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"panic: test timed out after 2s\n"}
{"Time":"2026-10-25T15:34:38.06487122+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\trunning tests:\n"}
{"Time":"2026-10-25T15:34:38.06491684+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t\tTestWaitsForever (2s)\n"}
{"Time":"2026-10-25T15:34:38.065034382+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
{"Time":"2026-10-25T15:34:38.065108573+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 33 [running]:\n"}
{"Time":"2026-10-25T15:34:38.065154754+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).startAlarm.func1()\n"}
{"Time":"2026-10-25T15:34:38.065386208+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2961 +0x34a\n"}
{"Time":"2026-10-25T15:34:38.065431878+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by time.goFunc\n"}
{"Time":"2026-10-25T15:34:38.0655151+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/time/sleep.go:182 +0x2d\n"}
{"Time":"2026-10-25T15:34:38.06553471+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
{"Time":"2026-10-25T15:34:38.065847805+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 1 [chan receive]:\n"}
{"Time":"2026-10-25T15:34:38.066038298+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*T).Run(0x3a0209bc8008, {0x5580aa?, 0x3a0209a9caa0?}, 0x6d5b10)\n"}
{"Time":"2026-10-25T15:34:38.066047488+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2268 +0x4f2\n"}
{"Time":"2026-10-25T15:34:38.066051368+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests.func1(0x3a0209bc8008)\n"}
{"Time":"2026-10-25T15:34:38.066056128+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2744 +0x37\n"}
{"Time":"2026-10-25T15:34:38.066059478+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8008, 0x3a0209a9cbc8)\n"}
{"Time":"2026-10-25T15:34:38.066064958+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-25T15:34:38.066069599+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.runTests({0x559017, 0x13}, {0x559017, 0x13}, 0x3a0209b8e0d8, {0x6f19f0, 0x2, 0x2}, {0xc2afa2bf832a3f56, 0x773908b5, ...})\n"}
{"Time":"2026-10-25T15:34:38.066083309+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2742 +0x510\n"}
{"Time":"2026-10-25T15:34:38.066086879+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.(*M).Run(0x3a0209b8c1e0)\n"}
{"Time":"2026-10-25T15:34:38.066090269+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2602 +0x6af\n"}
{"Time":"2026-10-25T15:34:38.066093429+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"main.main()\n"}
{"Time":"2026-10-25T15:34:38.066097569+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t_testmain.go:48 +0x9b\n"}
{"Time":"2026-10-25T15:34:38.066100639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\n"}
{"Time":"2026-10-25T15:34:38.066103769+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"goroutine 20 [sleep]:\n"}
{"Time":"2026-10-25T15:34:38.066106969+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"time.Sleep(0xdf8475800)\n"}
{"Time":"2026-10-25T15:34:38.066110279+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/runtime/time.go:368 +0x165\n"}
{"Time":"2026-10-25T15:34:38.066113639+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"example.com/timeout.TestWaitsForever(0x3a0209bc8488?)\n"}
{"Time":"2026-10-25T15:34:38.066117109+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/tmp/fix-1901b/timeout/p_test.go:12 +0x48\n"}
{"Time":"2026-10-25T15:34:38.066120239+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"testing.tRunner(0x3a0209bc8488, 0x6d5b10)\n"}
{"Time":"2026-10-25T15:34:38.066123509+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2195 +0xea\n"}
{"Time":"2026-10-25T15:34:38.066126839+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"created by testing.(*T).Run in goroutine 1\n"}
{"Time":"2026-10-25T15:34:38.066129989+01:00","Action":"output","Package":"example.com/timeout","Test":"TestWaitsForever","Output":"\t/usr/lib/go/src/testing/testing.go:2260 +0x4d4\n"}
{"Time":"2026-10-25T15:34:38.066986453+01:00","Action":"output","Package":"example.com/timeout","Output":"FAIL\texample.com/timeout\t2.017s\n","OutputType":"frame"}
{"Time":"2026-10-25T15:34:38.067002113+01:00","Action":"fail","Package":"example.com/timeout","Elapsed":2.017}
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,41 +0,0 @@
TAP version 13
# Subtest: passes before the crash
ok 1 - passes before the crash
---
duration_ms: 0.746694
type: 'test'
...
# Subtest: throws after it returned
ok 2 - throws after it returned
---
duration_ms: 0.763364
type: 'test'
...
# Subtest: waits after the throw
ok 3 - waits after the throw
---
duration_ms: 49.953893
type: 'test'
...
# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event.
# Subtest: test/b.test.ts
not ok 2 - test/b.test.ts
---
duration_ms: 155.864774
type: 'test'
location: '/tmp/1901k/node/crash/test/b.test.ts:1:1'
failureType: 'testCodeFailure'
exitCode: 1
signal: ~
error: 'test failed'
code: 'ERR_TEST_FAILURE'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 168.479173
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,42 +0,0 @@
TAP version 13
# retrying after Error: the store was not ready yet
# Subtest: passes and logs
ok 1 - passes and logs
---
duration_ms: 1.184595
type: 'test'
...
# Subtest: throws after it returned
ok 2 - throws after it returned
---
duration_ms: 0.748862
type: 'test'
...
# Subtest: waits after the throw
ok 3 - waits after the throw
---
duration_ms: 51.019875
type: 'test'
...
# Error: Test "throws after it returned" at test/b.test.ts:2:1 generated asynchronous activity after the test ended. This activity created the error "Error: the bus closed under the test" and would have caused the test to fail, but instead triggered an uncaughtException event.
# Subtest: test/b.test.ts
not ok 2 - test/b.test.ts
---
duration_ms: 172.91715
type: 'test'
location: '/tmp/1901k/node/logcrash/test/b.test.ts:1:1'
failureType: 'testCodeFailure'
exitCode: 1
signal: ~
error: 'test failed'
code: 'ERR_TEST_FAILURE'
...
1..4
# tests 4
# suites 0
# pass 3
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 185.744334
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,61 +0,0 @@
TAP version 13
# Subtest: the seat
# Subtest: passes inside
ok 1 - passes inside
---
duration_ms: 0.577396
type: 'test'
...
# Subtest: fails inside
not ok 2 - fails inside
---
duration_ms: 1.621198
type: 'test'
location: '/tmp/1901k/node/suite/test/a.test.ts:5:3'
failureType: 'testCodeFailure'
error: |-
Expected values to be strictly deep-equal:
+ actual - expected
 {
+ holders: 2
- holders: 1
 }
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected:
holders: 1
actual:
holders: 2
operator: 'deepStrictEqual'
stack: |-
TestContext.<anonymous> (file:///tmp/1901k/node/suite/test/a.test.ts:5:37)
Test.runInAsyncScope (node:async_hooks:226:14)
Test.run (node:internal/test_runner/test:1402:25)
Suite.processPendingSubtests (node:internal/test_runner/test:974:18)
Test.postRun (node:internal/test_runner/test:1542:19)
Test.run (node:internal/test_runner/test:1467:12)
async Promise.all (index 0)
async Suite.run (node:internal/test_runner/test:1905:7)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..2
not ok 1 - the seat
---
duration_ms: 3.295664
type: 'suite'
location: '/tmp/1901k/node/suite/test/a.test.ts:3:1'
failureType: 'subtestsFailed'
error: '1 subtest failed'
code: 'ERR_TEST_FAILURE'
...
1..1
# tests 2
# suites 1
# pass 1
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 115.948112
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,47 +0,0 @@
TAP version 13
# Subtest: passes in the first file
ok 1 - passes in the first file
---
duration_ms: 0.637465
type: 'test'
...
# node:internal/modules/run_main:111
# triggerUncaughtException(
# ^
# file:///tmp/1901k/node/syntax/test/b.test.ts:2
# test('never loads', () => {
# SyntaxError [ERR_INVALID_TYPESCRIPT_SYNTAX]: Expected ',', got '<eof>'
#  at parseTypeScript (node:internal/modules/typescript:61:36)
#  at processTypeScriptCode (node:internal/modules/typescript:129:37)
#  at Object.stripTypeScriptModuleTypes (node:internal/modules/typescript:172:22)
#  at stripTypeScriptModuleTypes (node:internal/modules/esm/translators:35:49)
#  at ModuleLoader.<anonymous> (node:internal/modules/esm/translators:688:29)
#  at \#translate (node:internal/modules/esm/loader:441:20)
#  at afterLoad (node:internal/modules/esm/loader:509:29)
#  at ModuleLoader.loadAndTranslate (node:internal/modules/esm/loader:514:12)
#  at \#getOrCreateModuleJobAfterResolve (node:internal/modules/esm/loader:567:36)
#  at afterResolve (node:internal/modules/esm/loader:614:52) {
# code: 'ERR_INVALID_TYPESCRIPT_SYNTAX'
# }
# Node.js v26.12.1
# Subtest: test/b.test.ts
not ok 2 - test/b.test.ts
---
duration_ms: 87.629815
type: 'test'
location: '/tmp/1901k/node/syntax/test/b.test.ts:1:1'
failureType: 'testCodeFailure'
exitCode: 1
signal: ~
error: 'test failed'
code: 'ERR_TEST_FAILURE'
...
1..2
# tests 2
# suites 0
# pass 1
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 106.551435
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,26 +0,0 @@
TAP version 13
# Subtest: passes before the wait
ok 1 - passes before the wait
---
duration_ms: 0.683015
type: 'test'
...
# Subtest: waits for an answer that never comes
not ok 2 - waits for an answer that never comes
---
duration_ms: 201.103775
type: 'test'
location: '/tmp/1901k/node/timeout/test/a.test.ts:3:1'
failureType: 'testTimeoutFailure'
error: 'test timed out after 200ms'
code: 'ERR_TEST_FAILURE'
...
1..2
# tests 2
# suites 0
# pass 1
# fail 0
# cancelled 1
# skipped 0
# todo 0
# duration_ms 308.920061
@@ -1,2 +0,0 @@
npm notice run test
npm notice run node --test --experimental-strip-types 'test/*.test.ts'
@@ -1,49 +0,0 @@
TAP version 13
# Subtest: passes in the first file
ok 1 - passes in the first file
---
duration_ms: 0.740124
type: 'test'
...
# judged against the seat as it stands
# Subtest: passes in the second file first
ok 2 - passes in the second file first
---
duration_ms: 0.916463
type: 'test'
...
# Subtest: fails in the second file
not ok 3 - fails in the second file
---
duration_ms: 1.719236
type: 'test'
location: '/tmp/1901k/node/two-files/test/b.test.ts:4:1'
failureType: 'testCodeFailure'
error: |-
one plus one is not three
2 !== 3
code: 'ERR_ASSERTION'
name: 'AssertionError'
expected: 3
actual: 2
operator: 'strictEqual'
stack: |-
TestContext.<anonymous> (file:///tmp/1901k/node/two-files/test/b.test.ts:6:10)
Test.runInAsyncScope (node:async_hooks:226:14)
Test.run (node:internal/test_runner/test:1402:25)
Test.processPendingSubtests (node:internal/test_runner/test:974:18)
Test.postRun (node:internal/test_runner/test:1542:19)
Test.run (node:internal/test_runner/test:1467:12)
async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3)
...
1..3
# tests 3
# suites 0
# pass 2
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 135.931121
@@ -1 +0,0 @@
seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'.
-201
View File
@@ -1,201 +0,0 @@
// Package conformance is the kit every build module's tests run (novox/hq issue 1901, its thorough fix): real output of
// the test runners, captured with the build nodes' own toolchains (captured/README.md), and what a build module must say
// of each failure in it under the failure contract (buildstep.Failure). A build module's test calls Run with its own parse
// path, the code that turns a runner's output into the failures of its test result; the kit fails the test when a
// failure is not named, its cause is missing or wrong, or a passing line stands in its place.
//
// A catalogue check (mesh-catalog checks/failurekit) refuses a build module with a test step whose tests do not call Run,
// and any build module whose tests do not call LeftRunning, the kit's case of steps a previous process left running
// (leftrunning.go, issue 1890).
package conformance
import (
"embed"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The runners the kit holds output of.
const (
// GoTest is `go test -json -count=1 ./...`: Output.Stdout is its output (go1.27.3).
GoTest = "go test -json"
// NodeTest is a package's `npm test` running `node --test --experimental-strip-types` with
// NODE_OPTIONS=buildstep.NodeTestOptions(<file>): Output.Report is the TAP file, Output.Stdout the script's own output
// and stderr, Output.Dir the package's directory as it was captured (node v26.12.1, npm 12.2.1).
NodeTest = "node --test"
// TypeCheck is tsc's type-check (`tsc --noEmit`, typescript 5.9.3): Output.Stdout is what it wrote; its failure
// names the test "type-check".
TypeCheck = "tsc"
)
// Output is one captured run of a runner, as a build module's parse path takes it.
type Output struct {
Stdout, Stderr string
// Report is what a runner wrote to a report file of its own (node --test's TAP), "" for the others.
Report string
// Dir is the directory the run was captured in: a node test's file is said relative to it.
Dir string
}
// Parse is a build module's own parse path: the failures its test result would carry for one run that failed.
type Parse func(Output) []buildstep.Failure
// Case is one captured run and what its failure must say.
type Case struct {
Name string
Runner string
// Test is the failing test the failure names (for a package that stopped, the tests that were running; "" for none).
Test string
// File, when set, is where the failing test is.
File string
// Cause holds what the failure's cause must contain.
Cause []string
// Passing are the names of tests that passed in the run, and what they logged: no failure's words may hold them.
Passing []string
}
//go:embed captured
var captured embed.FS
// Cases are the kit's cases of a runner.
func Cases(runner string) []Case {
var out []Case
for _, c := range cases {
if c.Runner == runner {
out = append(out, c)
}
}
return out
}
var cases = []Case{
{Name: "passfirst", Runner: GoTest, Test: "TestTheSecondPackageFails", Cause: []string{"the seat has 2 holders, want 1"},
Passing: []string{"TestTheFirstPackagePasses", "TestTheSecondPackagePassesFirst"}},
{Name: "subtests", Runner: GoTest, Test: "TestTheTable/fails", Cause: []string{"got 3 machines, want 4"}, Passing: []string{"TestTheTable/passes"}},
{Name: "logthenfatal", Runner: GoTest, Test: "TestThree", Cause: []string{"3 subscription(s) derive to a subject no emitter owns:"}},
{Name: "panicking", Runner: GoTest, Test: "TestPanics", Cause: []string{"panic: assignment to entry in nil map"}},
{Name: "timeout", Runner: GoTest, Test: "TestWaitsForever", Cause: []string{"panic: test timed out after 2s", "TestWaitsForever"}},
{Name: "deeptimeout", Runner: GoTest, Test: "TestHoldsTheSeat, TestWaitsForTheBus", Cause: []string{"panic: test timed out after 2s", "TestHoldsTheSeat", "TestWaitsForTheBus"}},
{Name: "buildfail", Runner: GoTest, Cause: []string{`cannot use "seat" (untyped string constant) as int value`}},
{Name: "exiting", Runner: GoTest, Test: "TestExits", Cause: []string{"exited", "TestExits"}},
{Name: "killed", Runner: GoTest, Test: "TestKilled", Cause: []string{"signal: killed"}},
{Name: "passthenfail", Runner: GoTest, Cause: []string{"panic: the bus closed after the tests: nats: connection closed"}},
{Name: "mainexit", Runner: GoTest, Cause: []string{"passed", "exited"}},
{Name: "race", Runner: GoTest, Test: "TestRacesOnTheSeat", Cause: []string{"DATA RACE", "Write at", "example.com/race.TestRacesOnTheSeat.func1()", "p_test.go:13", "created at example.com/race.TestRacesOnTheSeat()"}},
{Name: "subpanic", Runner: GoTest, Test: "TestTable/panics", Cause: []string{"panic: assignment to entry in nil map"}, Passing: []string{"TestTable/passes"}},
{Name: "racemain", Runner: GoTest, Cause: []string{"DATA RACE", "Write at", "example.com/racemain.TestMain.func1()", "p_test.go:15", "created at example.com/racemain.TestMain()"}},
{Name: "two-files", Runner: NodeTest, Test: "fails in the second file", File: "test/b.test.ts:4:1", Cause: []string{"one plus one is not three"},
Passing: []string{"passes in the first file", "passes in the second file first"}},
{Name: "suite", Runner: NodeTest, Test: "fails inside", File: "test/a.test.ts:5:3", Cause: []string{"Expected values to be strictly deep-equal:"},
Passing: []string{"passes inside"}},
{Name: "crash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"},
Passing: []string{"passes before the crash"}},
{Name: "logcrash", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"the bus closed under the test"},
Passing: []string{"passes and logs", "retrying after Error: the store was not ready yet"}},
{Name: "timeout", Runner: NodeTest, Test: "waits for an answer that never comes", File: "test/a.test.ts:3:1", Cause: []string{"test timed out after 200ms"},
Passing: []string{"passes before the wait"}},
{Name: "syntax", Runner: NodeTest, Test: "test/b.test.ts", File: "test/b.test.ts:1:1", Cause: []string{"SyntaxError", "Expected ',', got '<eof>'"},
Passing: []string{"passes in the first file"}},
{Name: "typeerror", Runner: TypeCheck, Test: "type-check", Cause: []string{"seat.ts(2,14): error TS2322: Type 'number' is not assignable to type 'string'."}},
}
// Load is the captured output of a case.
func Load(c Case) (Output, error) {
switch c.Runner {
case GoTest:
b, err := captured.ReadFile("captured/go/" + c.Name + ".jsonl")
return Output{Stdout: string(b)}, err
case NodeTest:
report, err := captured.ReadFile("captured/node/" + c.Name + ".tap")
if err != nil {
return Output{}, err
}
console, err := captured.ReadFile("captured/node/" + c.Name + ".console")
return Output{Report: string(report), Stdout: string(console), Dir: "/tmp/1901k/node/" + c.Name}, err
default:
b, err := captured.ReadFile("captured/tsc/" + c.Name + ".console")
return Output{Stdout: string(b), Dir: "/tmp/1901k/tsc/" + c.Name}, err
}
}
// Run runs every case of runner through parse, each as a subtest, and fails a case whose failure does not keep the
// contract: exactly one failure, naming the failing test (and its file, where the runner says it), with a cause that
// holds what the case says and is no passing line, an output that starts with the cause, stays within its bound and
// holds no passing line and no passing test's name.
func Run(t *testing.T, runner string, parse Parse) {
t.Helper()
cs := Cases(runner)
if len(cs) == 0 {
t.Fatalf("the kit holds no case of the runner %q", runner)
}
for _, c := range cs {
t.Run(c.Name, func(t *testing.T) {
out, err := Load(c)
if err != nil {
t.Fatal(err)
}
for _, problem := range Judge(c, parse(out)) {
t.Error(problem)
}
})
}
}
// Judge says what is wrong with the failures a parse path made of a case; nothing when they keep the contract.
func Judge(c Case, failed []buildstep.Failure) []string {
var problems []string
say := func(s string) { problems = append(problems, c.Runner+" "+c.Name+": "+s) }
if len(failed) != 1 {
say(fmt.Sprintf("want one failure, got %d: %s", len(failed), describe(failed)))
if len(failed) == 0 {
return problems
}
}
f := failed[0]
if f.Test != c.Test {
say("the failure names the test " + quote(f.Test) + ", want " + quote(c.Test))
}
if c.File != "" && f.File != c.File {
say("the failure's file is " + quote(f.File) + ", want " + quote(c.File))
}
if why := buildstep.Hidden(f); why != "" {
say("the failure hides its cause: " + why)
}
for _, want := range c.Cause {
if !strings.Contains(f.Cause, want) {
say("the cause " + quote(f.Cause) + " does not hold " + quote(want))
}
}
if first := strings.SplitN(f.Output, "\n", 2)[0]; strings.TrimSpace(first) != strings.TrimSpace(f.Cause) {
say("the output starts with " + quote(first) + ", not with the cause")
}
if len(f.Output) > buildstep.OutputBytes+buildstep.LineBytes+64 {
say(fmt.Sprintf("the output holds %d bytes, over its bound", len(f.Output)))
}
for _, l := range strings.Split(f.Output, "\n") {
if buildstep.PassingLine(l) {
say("the output keeps the passing line " + quote(l))
}
}
for _, p := range c.Passing {
if strings.Contains(f.Output, p+"\n") || strings.HasSuffix(f.Output, p) || strings.Contains(f.Cause, p) || strings.Contains(f.Output, "- "+p) {
say("the failure's words hold the passing test " + quote(p))
}
}
return problems
}
func describe(failed []buildstep.Failure) string {
var s []string
for _, f := range failed {
s = append(s, quote(f.Package+" "+f.Test)+" ("+quote(f.Cause)+")")
}
return strings.Join(s, ", ")
}
func quote(s string) string { return fmt.Sprintf("%q", s) }
@@ -1,167 +0,0 @@
package conformance
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The failure contract (issue 1901): the SDK's own parsers keep it on every captured case of the kit (1901/AC6); the
// kit refuses what the build modules sent before it; the contract's own rules; and the Go reader's older guarantees.
func TestTheFailureContract(t *testing.T) {
t.Run("the SDK parsers keep it", func(t *testing.T) {
Run(t, GoTest, func(o Output) []buildstep.Failure { return buildstep.ReadGoTest(o.Stdout, ".").Failed })
Run(t, NodeTest, func(o Output) []buildstep.Failure { return buildstep.ReadTAP(".", "test", o.Dir, o.Report, o.Stdout) })
Run(t, TypeCheck, func(o Output) []buildstep.Failure {
return []buildstep.Failure{buildstep.ConsoleFailure(".", "type-check", o.Stdout+o.Stderr)}
})
})
// 1890/AC7: the SDK's start sweep keeps the left-running case, and the kit refuses a sweep that ends nothing or ends
// everything.
t.Run("the left-running case", func(t *testing.T) {
LeftRunning(t, func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any)) {
buildstep.EndStaleAtStart(ctx, scan, node, before, logf)
})
if p := JudgeLeftRunning(func(context.Context, buildstep.Scan, string, time.Time, func(string, ...any)) {}); len(p) != 11 {
t.Errorf("a sweep that ends nothing: %d problems, want 11 (three steps not ended and unsaid, one owner unnamed, two asks not deleted and unsaid):\n%s", len(p), strings.Join(p, "\n"))
}
wrongNode := func(ctx context.Context, scan buildstep.Scan, _ string, before time.Time, logf func(string, ...any)) {
buildstep.EndStaleAtStart(ctx, scan, "kitother", before, logf)
}
if p := JudgeLeftRunning(wrongNode); len(p) == 0 {
t.Error("a sweep of another node's steps is not refused")
}
})
// node's output cut to its first fifteen lines with no cause, whose first line was a passing test of the first file
// (mesh-build-node's lines() before the contract), and a Go failure keeping its first line.
t.Run("the kit refuses a failure that hides its cause", func(t *testing.T) {
firstLines := func(s string) string {
var keep []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" && len(keep) < 15 {
keep = append(keep, l)
}
}
return strings.Join(keep, "\n")
}
for _, c := range []struct {
runner, name string
old func(Output) []buildstep.Failure
}{
{NodeTest, "two-files", func(o Output) []buildstep.Failure {
return []buildstep.Failure{{Package: ".", Test: "test", Output: firstLines(o.Report)}}
}},
{GoTest, "passfirst", func(o Output) []buildstep.Failure {
return []buildstep.Failure{{Package: "example.com/passfirst/b", Test: "TestTheSecondPackageFails", Cause: "ok \texample.com/passfirst/a", Output: "--- PASS: TestTheSecondPackagePassesFirst"}}
}},
} {
for _, k := range Cases(c.runner) {
if k.Name != c.name {
continue
}
out, err := Load(k)
if err != nil {
t.Fatal(err)
}
if problems := Judge(k, c.old(out)); len(problems) == 0 {
t.Errorf("%s %s: the kit passed a failure that hides its cause", c.runner, c.name)
}
}
}
})
// What a passing line is (a test's own line starting "ok " is none), when a failure hides its cause, and that a long
// failure keeps its last lines within the bound and starts with a real line for an older checker, which reads only
// the first line of the output cut to 120 characters (1901/AC1, AC3).
t.Run("its rules", func(t *testing.T) {
for _, c := range []struct {
line string
passing bool
}{
{"✔ the same merge has the same id (8.0ms)", true}, {"ok 1 - passes", true}, {" ok 3 - inside", true},
{"ok \texample.com/a\t0.01s", true}, {"PASS", true}, {"--- PASS: TestA (0.00s)", true},
{" --- PASS: TestA/sub (0.00s)", true}, {"✖ fails (1ms)", false}, {"not ok 2 - fails", false},
{"okay, the seat moved", false}, {"ok then: the store refused the write", false}, {"ok the seat", false}, {"FAIL", false},
} {
if buildstep.PassingLine(c.line) != c.passing {
t.Errorf("PassingLine(%q) = %v, want %v", c.line, !c.passing, c.passing)
}
}
if buildstep.Hidden(buildstep.Failure{Cause: "the seat has 2 holders"}) != "" || buildstep.Hidden(buildstep.Failure{}) == "" ||
buildstep.Hidden(buildstep.Failure{Cause: "✔ passes (1ms)"}) == "" {
t.Error("Hidden does not tell a real cause from none and from a passing line")
}
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
}
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine before issue 1901)
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l[:min(len(l), 120)]
}
}
return ""
}
for _, cause := range []string{"the real cause", ""} {
out := buildstep.Clip(lines, cause)
if len(out) > buildstep.OutputBytes+buildstep.LineBytes {
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), buildstep.OutputBytes)
}
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") {
t.Errorf("1901/AC1: the last lines or the marker are lost: %q...%q", out[:40], out[len(out)-60:])
}
if got := oldFirst(out); strings.HasPrefix(got, "... (") || got == "" || (cause != "" && got != cause) {
t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got)
}
}
})
// The Go reader (moved from mesh-build-go): an older checker, which knows no "cause" field, still reads a failure and
// gets at least the lines it kept (1901/AC3); a test's indented log line saying "panic:" is not the cause, go's own at
// column 0 is (1901/AC4); and each package counts its own skipped tests (issue 890).
t.Run("the Go reader", func(t *testing.T) {
read := func(name string) string {
raw, err := captured.ReadFile("captured/go/" + name + ".jsonl")
if err != nil {
t.Fatal(err)
}
return string(raw)
}
b, _ := json.Marshal(buildstep.ReadGoTest(read("logthenfatal"), ".").Failed)
var old []struct {
Output string `json:"output,omitempty"`
}
if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") {
t.Errorf("1901/AC3: an older checker reads %+v, %v", old, err)
}
lines := strings.SplitAfter(read("deeptimeout"), "\n")
added := `{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" p_test.go:9: the child said:\n"}` + "\n" +
`{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" panic: child retried and recovered\n"}` + "\n" +
`{"Action":"output","Package":"example.com/deeptimeout","Test":"TestHoldsTheSeat","Output":" WARNING: DATA RACE\n"}` + "\n"
if got := buildstep.ReadGoTest(strings.Join(lines[:10], "")+added+strings.Join(lines[10:], ""), ".").Failed; len(got) != 1 || !strings.HasPrefix(got[0].Cause, "panic: test timed out after 2s") {
t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", got)
}
res := buildstep.ReadGoTest(`{"Action":"run","Package":"m/a","Test":"TestA"}
{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0}
{"Action":"run","Package":"m/b","Test":"TestB"}
{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0}
{"Action":"pass","Package":"m/a","Elapsed":0}
{"Action":"pass","Package":"m/b","Elapsed":0}
`, ".")
got := map[string]int{}
for _, p := range res.Packages {
got[p.Package] = p.Skipped
}
if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 {
t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got)
}
})
}
-212
View File
@@ -1,212 +0,0 @@
package conformance
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A step left running by a previous process** (novox/hq issue 1890, note 21): when a delivery replaced mesh-build-go
// on a node while a check ask ran, the new process's start sweep let the ask's key stand as running, and it held one
// of the node's build slots until a hand end. Every build module runs this case (LeftRunning) through the start sweep
// its main runs: the hand-started steps a previous process left are ended at once and loud, its asks' keys deleted (the
// stream redelivers the ask, novox/hq ADR 0525 §6, and its asker keeps waiting for that), what this process runs kept. A
// catalogue check (mesh-catalog checks/failurekit) refuses a build module whose tests do not call LeftRunning.
// Sweep is a build module's own start sweep, as its main runs it once at its start: it ends node's steps in scan that
// no process of the module runs any more (buildstep.EndStaleAtStart, here with the runtime already serving), before
// being the start of the process, saying each end with logf.
type Sweep func(ctx context.Context, scan buildstep.Scan, node string, before time.Time, logf func(string, ...any))
// LeftNode is the node the case's steps are of.
const LeftNode = "kitnode"
// leftCase is one step in the steps state before the sweep, and whether the sweep must end it.
type leftCase struct {
id, tool, owner, state string
node string
age time.Duration // started this long before the process began (negative: after it)
ended bool
deleted bool // a gone process's ask: its key is deleted, not ended
}
// gone is a previous process's owner on the same node.
var gone = "b00t-of-the-kit.4242.mvp0000"
func leftCases() []leftCase {
return []leftCase{
{id: "mvpwlkzi7e6ca8", tool: "check ask", owner: gone, age: 5 * time.Minute, deleted: true}, // note 21's ghost
{id: "mvpwlkzi7e6ca9", tool: "delivery ask", owner: gone, age: -time.Second, deleted: true},
{id: "gonehand1", tool: "build_x_test", owner: gone, age: time.Minute, ended: true},
{id: "oldhand1", tool: "build_x_fetch", age: time.Hour, ended: true}, // no owner: an older build's
{id: "mvpwlkzi7e6caa", tool: "check ask", age: time.Hour, ended: true}, // no owner, past the bound
{id: "mvpwlkzi7e6cab", tool: "check ask", age: 5 * time.Minute}, // no owner, the stream's still
{id: "mvpwlkzi7e6cac", tool: "check ask", owner: buildstep.Owner, age: time.Minute}, // this process's
{id: "minehand1", tool: "build_x_test", owner: buildstep.Owner, age: -time.Second},
{id: "donehand1", tool: "build_x_test", owner: gone, age: time.Hour, state: delivery.StepPassed},
{id: "otherhand1", tool: "build_x_test", owner: gone, age: time.Hour, node: "kitother"},
}
}
// LeftRunning runs the case through sweep and fails the test for each step the sweep ended or kept wrongly, or ended
// without saying it.
func LeftRunning(t *testing.T, sweep Sweep) {
t.Helper()
for _, problem := range JudgeLeftRunning(sweep) {
t.Error(problem)
}
}
// JudgeLeftRunning says what is wrong with what sweep did to the case; nothing when it keeps the rule.
func JudgeLeftRunning(sweep Sweep) []string {
st := &steps{vals: map[string][]byte{}, revs: map[string]uint64{}}
before := time.Now()
for _, c := range leftCases() {
node, state := c.node, c.state
if node == "" {
node = LeftNode
}
if state == "" {
state = delivery.StepRunning
}
key, _ := delivery.StepKey(node, c.id)
raw, err := delivery.EncodeStep(delivery.Step{ID: c.id, Tool: c.tool, Node: node, State: state, Started: before.Add(-c.age).UTC(), Owner: c.owner})
if err != nil {
return []string{"the kit's step " + c.id + ": " + err.Error()}
}
st.set(key, raw)
}
served := make(chan struct{})
close(served)
was := buildstep.StaleServed
buildstep.StaleServed = func() <-chan struct{} { return served }
defer func() { buildstep.StaleServed = was }()
var mu sync.Mutex
var said []string
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sweep(ctx, st.scan(), LeftNode, before, func(f string, a ...any) {
mu.Lock()
defer mu.Unlock()
said = append(said, fmt.Sprintf(f, a...))
})
mu.Lock()
log := strings.Join(said, "\n")
mu.Unlock()
var problems []string
for _, c := range leftCases() {
node := c.node
if node == "" {
node = LeftNode
}
key, _ := delivery.StepKey(node, c.id)
raw := st.get(key)
if c.deleted {
if raw != nil {
problems = append(problems, fmt.Sprintf("left running: the ask %s of the gone owner %q is still held after the sweep (%s); "+
"its key is deleted, never ended: the stream redelivers it (ADR 0525 §6)", key, c.owner, raw))
}
if !strings.Contains(log, key) || !strings.Contains(log, c.owner) {
problems = append(problems, "left running: the delete of "+key+" is not said with its owner "+c.owner)
}
continue
}
if raw == nil {
problems = append(problems, "left running: the step "+key+" is gone after the sweep, want it kept or ended")
continue
}
s, err := delivery.DecodeStep(raw)
if err != nil {
problems = append(problems, key+": "+err.Error())
continue
}
want := c.state
if want == "" {
want = delivery.StepRunning
}
if c.ended {
want = delivery.StepFailed
}
if s.State != want {
problems = append(problems, fmt.Sprintf("left running: the step %s (%s, owner %q, %s before the start) is %s after the sweep, want %s", key, c.tool, c.owner, c.age, s.State, want))
}
if c.ended && !strings.Contains(log, key) {
problems = append(problems, "left running: the end of "+key+" is not said")
}
if c.ended && c.owner != "" && !strings.Contains(log, c.owner) {
problems = append(problems, "left running: the end of "+key+" does not name its owner "+c.owner)
}
}
sort.Strings(problems)
return problems
}
// steps is a steps state with revisions, whose Update lands only at the revision read (stdio.ErrChanged otherwise).
type steps struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
}
func (s *steps) set(key string, raw []byte) {
s.mu.Lock()
defer s.mu.Unlock()
s.vals[key], s.revs[key] = raw, s.revs[key]+1
}
func (s *steps) get(key string) []byte {
s.mu.Lock()
defer s.mu.Unlock()
return s.vals[key]
}
func (s *steps) scan() buildstep.Scan {
return buildstep.Scan{
Keys: func() ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for k := range s.vals {
out = append(out, k)
}
return out, nil
},
Get: func(key string) (json.RawMessage, uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.vals[key], s.revs[key], nil
},
Delete: func(key string, revision uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.revs[key] != revision {
return stdio.ErrChanged
}
delete(s.vals, key)
s.revs[key]++
return nil
},
Update: func(key string, value any, revision uint64) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.revs[key] != revision {
return 0, stdio.ErrChanged
}
s.vals[key], s.revs[key] = raw, revision+1
return revision + 1, nil
},
}
}
-14
View File
@@ -1,14 +0,0 @@
package buildstep
import (
"os"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
)
// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share from the
// word the composer gives the module (buildcpu.ShareWord), divided by the module's own capacity, own, over the machine's
// processors. Without the word, no thread limit: Threads is 0.
func NodeLimits(own int) buildcpu.Limits {
return buildcpu.FromWords(os.Getenv, own)
}
-147
View File
@@ -1,147 +0,0 @@
package buildstep
// The failure contract of a build step's test level (novox/hq issue 1901, its thorough fix): what a build module says of
// each test that failed, the same for every language. Each build module used to parse its own test runner's output, and
// a fix in one never reached the others: a failed Go test kept a t.Logf line, a failed node --test run kept a passing
// '✔' line of its first file. The parsers that fill the contract live here (gotest.go, tap.go) and the conformance kit
// (package conformance) holds every build module to them with captured runner output.
import (
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/sdkversion"
)
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
// always used; File is new and an older reader ignores it.
type Failure struct {
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
Package string `json:"package"`
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
Test string `json:"test,omitempty"`
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
File string `json:"file,omitempty"`
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
Cause string `json:"cause,omitempty"`
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
// only its first line.
Output string `json:"output,omitempty"`
}
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
// assertion message and stays far under a bus message's or a verdict's bound.
const (
OutputBytes = 4096
LineBytes = 1024
)
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
// its cause ("the build module hid the failure (<by>)").
func By(module string) string {
return module + " with mesh-sdk " + sdkversion.Version()
}
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
func PassingLine(l string) bool {
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
return true
}
t := strings.TrimSpace(l)
switch {
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
return true
}
return tapOk(t)
}
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
func Hidden(f Failure) string {
c := strings.TrimSpace(f.Cause)
switch {
case c == "":
return "it sent no cause"
case PassingLine(c):
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
}
return ""
}
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
// Passing lines are dropped.
func Clip(lines []string, cause string) string {
own := make([]string, 0, len(lines))
for _, l := range lines {
if !PassingLine(l) {
own = append(own, l)
}
}
lines = own
size, from := 0, len(lines)
for from > 0 {
n := len(CutLine(lines[from-1])) + 1
if size+n > OutputBytes && from < len(lines) {
break
}
size += n
from--
}
kept := make([]string, 0, len(lines)-from+2)
head := strings.TrimSpace(cause)
for i := 0; head == "" && i < from; i++ {
head = strings.TrimSpace(lines[i])
}
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
kept = append(kept, CutLine(head))
}
if from > 0 {
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
}
for _, l := range lines[from:] {
kept = append(kept, CutLine(l))
}
return strings.Join(kept, "\n")
}
// CutLine cuts a line to LineBytes, on a character boundary.
func CutLine(l string) string {
if len(l) <= LineBytes {
return l
}
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
}
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
func ConsoleFailure(pkg, test, out string) Failure {
var lines []string
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
lines = append(lines, strings.TrimRight(l, " \t"))
}
}
cause := ""
for _, l := range lines {
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
cause = t
break
}
}
for _, l := range lines {
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
cause = t
}
}
if cause == "" {
cause = test + " failed and wrote nothing that says why"
}
cause = CutLine(cause)
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
}
+11 -6
View File
@@ -71,7 +71,7 @@ func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit,
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...) r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
} }
return r.Stdout, nil return r.Stdout, nil
} }
@@ -128,11 +128,7 @@ func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, comm
git := func(args ...string) error { git := func(args ...string) error {
r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...) r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...)
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
verb := args[0] return fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
verb = args[2]
}
return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr))
} }
return nil return nil
} }
@@ -194,3 +190,12 @@ func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
} }
return nil return nil
} }
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
-409
View File
@@ -1,409 +0,0 @@
package buildstep
// The reader of `go test -json` (moved whole from mesh-catalog's mesh-build-go, issue 1901's thorough fix): per package
// what ran and for how long, and each failure under the contract (failure.go), its real cause too when the failure is no
// test's assertion (a panic, a timeout, a build error, an exit, a kill, a race).
import (
"bufio"
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
)
// GoPackage is one package's part of a Go test level.
type GoPackage struct {
Dir string `json:"dir"`
Package string `json:"package"`
Passed bool `json:"passed"`
Elapsed float64 `json:"elapsed"`
Tests int `json:"tests"`
// Skipped is how many of the package's tests skipped.
Skipped int `json:"skipped,omitempty"`
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
NoTests bool `json:"no_tests,omitempty"`
}
// TestTime is one test and the seconds it took.
type TestTime struct {
Package string `json:"package"`
Test string `json:"test"`
Elapsed float64 `json:"elapsed"`
}
// GoTest is what one go.mod directory's `go test -json` said.
type GoTest struct {
Packages []GoPackage
Tests int
Skipped int
Times []TestTime
Failed []Failure
// Elapsed is the slowest package's running time in seconds.
Elapsed float64
// Noise is the lines that were no event: what go said when it could not run the tests at all.
Noise []string
}
// ReadGoTest reads the output of `go test -json` run in the go.mod directory dir.
func ReadGoTest(out, dir string) GoTest {
r := &goReader{dir: dir, pkgs: map[string]*GoPackage{}, builds: map[string][]string{}, pkgOut: map[string][]string{},
testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{},
running: map[string][]string{}, passed: map[string]bool{}, built: map[string]bool{}}
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 1<<20), 1<<26)
for sc.Scan() {
var ev goEvent
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
if l := strings.TrimSpace(sc.Text()); l != "" {
r.res.Noise = append(r.res.Noise, l)
}
continue
}
r.event(ev)
}
r.finish()
return r.res
}
// goEvent is one line of `go test -json` (test2json, and the build events go test adds).
type goEvent struct {
Action string
Package string
Test string
Output string
OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines
ImportPath string
FailedBuild string
Elapsed float64
}
type goReader struct {
res GoTest
dir string
pkgs map[string]*GoPackage
order []string
builds map[string][]string // build output by import path
pkgOut map[string][]string
testOut map[string][]string // by package + "\x00" + test
causes map[string]string // the first error line of a test, by the same key
failedIn map[string]bool // packages with a failed test
// running is the tests that started and have not ended, by package, in the order they started: when a package
// fails with no failed test, these were running when its test program stopped.
running map[string][]string
// passed says a package's test program printed "PASS" before it failed; built says it failed to build.
passed, built map[string]bool
}
func (r *goReader) pkg(name string) *GoPackage {
p := r.pkgs[name]
if p == nil {
p = &GoPackage{Dir: r.dir, Package: name}
r.pkgs[name] = p
r.order = append(r.order, name)
}
return p
}
func keepLine(lines []string, out string) []string {
if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") {
lines = append(lines, t)
}
return lines
}
func (r *goReader) event(ev goEvent) {
switch {
case ev.Package == "" && ev.ImportPath != "":
if ev.Action == "build-output" {
r.builds[ev.ImportPath] = keepLine(r.builds[ev.ImportPath], ev.Output)
}
case ev.Package == "":
case ev.Test == "":
p := r.pkg(ev.Package)
switch ev.Action {
case "output":
// go test's own summary of a package is never a failure's words: a "PASS" before a "FAIL <pkg>" says only
// that the tests passed and the program failed after them.
switch l := strings.TrimRight(ev.Output, "\r\n"); {
case l == "PASS":
r.passed[ev.Package] = true
case goSummaryLine(l):
default:
r.pkgOut[ev.Package] = keepLine(r.pkgOut[ev.Package], ev.Output)
}
case "pass":
p.Passed, p.Elapsed = true, ev.Elapsed
case "fail":
p.Passed, p.Elapsed = false, ev.Elapsed
if ev.FailedBuild != "" {
r.built[ev.Package] = true
r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...)
}
case "skip":
p.Passed, p.NoTests = true, true
}
default:
key := ev.Package + "\x00" + ev.Test
switch ev.Action {
case "output":
r.testOut[key] = keepLine(r.testOut[key], ev.Output)
if ev.OutputType == "error" && r.causes[key] == "" {
r.causes[key] = goErrorWords(ev.Output)
}
case "run":
r.running[ev.Package] = append(r.running[ev.Package], ev.Test)
case "pass", "fail", "skip":
r.running[ev.Package] = slices.DeleteFunc(r.running[ev.Package], func(t string) bool { return t == ev.Test })
p := r.pkg(ev.Package)
p.Tests++
r.res.Tests++
if ev.Action == "skip" {
r.res.Skipped++
p.Skipped++
}
r.res.Times = append(r.res.Times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
if ev.Action == "fail" {
r.failedIn[ev.Package] = true
if r.subtestFailed(ev.Package, ev.Test) {
return // a parent failed only by its subtest is that subtest's failure, said once
}
if r.subtestTakesTheCause(ev.Package, ev.Test) {
return // a subtest's panic, which go files under its parent, is said on the subtest
}
r.res.Failed = append(r.res.Failed, r.testFailure(ev.Package, ev.Test))
}
}
}
}
// subtestFailed says a failure of test under pkg was already reported for one of its subtests (they end first), each with
// a cause of its own, and the test reported no error and no runtime cause (a subtest's panic) of its own.
func (r *goReader) subtestFailed(pkg, test string) bool {
key := pkg + "\x00" + test
if r.causes[key] != "" || goRuntimeCause(r.testOut[key]) != "" {
return false
}
for _, f := range r.res.Failed {
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") {
return true
}
}
return false
}
// noMessage is the cause of a test that failed and said nothing of why.
const noMessage = "the test failed without an error message (t.Fail or t.FailNow)"
// subtestTakesTheCause gives a parent's runtime cause (a subtest's panic: go test -json files its lines under the
// parent) to the subtests of that parent that failed with no message of their own, and says it did. A parent with an
// error line of its own, or whose failed subtests each said why, is reported as itself.
func (r *goReader) subtestTakesTheCause(pkg, test string) bool {
key := pkg + "\x00" + test
if r.causes[key] != "" {
return false
}
cause := goRuntimeCause(r.testOut[key])
if cause == "" {
return false
}
took := false
for i, f := range r.res.Failed {
if f.Package == pkg && strings.HasPrefix(f.Test, test+"/") && f.Cause == noMessage {
r.res.Failed[i].Cause, r.res.Failed[i].Output = cause, Clip(r.testOut[key], cause)
took = true
}
}
return took
}
// testFailure is a test that failed: its cause is its first error line, the race it ran into, the runtime's line, or
// words saying it failed without a message.
func (r *goReader) testFailure(pkg, test string) Failure {
key := pkg + "\x00" + test
cause := r.causes[key]
if race := goRaceCause(r.testOut[key]); race != "" && (cause == "" || strings.HasPrefix(cause, "race detected")) {
cause = race // the race itself, not testing's "race detected during execution of test"
} else if cause == "" {
cause = goRuntimeCause(r.testOut[key])
}
if cause == "" {
cause = noMessage
}
return Failure{Package: pkg, Test: test, Output: Clip(r.testOut[key], cause), Cause: cause}
}
// goSummaryLine says a line is go test's summary of a package, in go's exact forms only: "PASS" or "FAIL" alone,
// "ok \t<pkg>\t<time>" and "FAIL\t<pkg>\t<time>" (a line of the package's own that starts "ok " is kept).
func goSummaryLine(l string) bool {
return l == "PASS" || l == "FAIL" || strings.HasPrefix(l, "ok \t") || strings.HasPrefix(l, "FAIL\t")
}
// goRuntimeCause is the cause in a failure's output that is not a test's assertion: a race the race detector reported;
// else the first "panic:" line, a timeout's with the tests it names as running; else the first "fatal error:" or
// "signal:" line; "" when there is none. It reads every line, so a long goroutine dump cannot hide it. **It matches only
// lines at column 0**, where go prints the runtime's: a test's indented log line holding the same words is not the cause.
func goRuntimeCause(lines []string) string {
if race := goRaceCause(lines); race != "" {
return race
}
for _, prefix := range []string{"panic: ", "fatal error: ", "signal: "} {
for i, l := range lines {
l = strings.TrimRight(l, "\r\n")
if !strings.HasPrefix(l, prefix) {
continue
}
if strings.HasPrefix(l, "panic: test timed out after") {
var names []string
for _, n := range lines[i+1:] {
if !strings.HasPrefix(n, "\t\t") {
if strings.TrimSpace(n) == "running tests:" {
continue
}
break
}
names = append(names, strings.TrimSpace(n))
}
if len(names) > 0 {
l += "; running tests: " + strings.Join(names, ", ")
}
}
return CutLine(l)
}
}
return ""
}
// goRaceCause is the first race the race detector reported in a failure's output, in one line: its access and the frame
// that made it, and where the goroutine that made it was created. "" when the output holds no race.
func goRaceCause(lines []string) string {
at := -1
for i, l := range lines {
if strings.TrimRight(l, "\r\n") == "WARNING: DATA RACE" {
at = i
break
}
}
if at < 0 {
return ""
}
frame := func(i int) string {
var fn, file string
for _, l := range lines[i+1:] {
if l = strings.TrimSpace(l); l == "" {
break
}
if fn == "" {
fn = l
continue
}
file = l
if j := strings.LastIndexByte(file, '/'); j >= 0 {
file = file[j+1:]
}
if j := strings.Index(file, " +0x"); j >= 0 {
file = file[:j]
}
break
}
if file != "" {
return fn + " (" + file + ")"
}
return fn
}
words := "WARNING: DATA RACE"
for i := at + 1; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if l == "" || strings.HasPrefix(l, "===") {
continue
}
words += ": " + strings.TrimSuffix(l, ":")
if f := frame(i); f != "" {
words += " in " + f
}
break
}
for i := at + 1; i < len(lines); i++ {
l := strings.TrimSpace(lines[i])
if strings.HasPrefix(l, "==================") {
break
}
if strings.HasPrefix(l, "Goroutine ") && strings.HasSuffix(l, " created at:") {
if f := frame(i); f != "" {
words += "; its goroutine created at " + f
}
break
}
}
return CutLine(words)
}
// goBuildCause is the first error line of a failed build: the first line that is not go's "# <package>" heading.
func goBuildCause(lines []string) string {
for _, l := range lines {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "# ") {
return CutLine(l)
}
}
return ""
}
// goTestNames names the tests that were running, at most three, and how many more.
func goTestNames(tests []string) string {
if len(tests) <= 3 {
return strings.Join(tests, ", ")
}
return fmt.Sprintf("%s and %d more", strings.Join(tests[:3], ", "), len(tests)-3)
}
// packageFailure is a package that failed with no failed test to name it: its test program did not build, panicked,
// timed out, was killed or exited. It names the tests that were running, keeps their output with the package's, and
// says the cause.
func (r *goReader) packageFailure(name string) Failure {
running := r.running[name]
var lines []string
for _, t := range running {
lines = append(lines, r.testOut[name+"\x00"+t]...)
}
lines = append(lines, r.pkgOut[name]...)
cause := goRuntimeCause(lines)
switch {
case cause != "":
case r.built[name]:
cause = goBuildCause(lines)
case len(running) > 0:
cause = "the test program exited while " + goTestNames(running) + " ran, with no panic: an os.Exit or a signal (go test -json reports no exit status)"
case r.passed[name]:
cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests (go test -json reports no exit status)"
}
if cause == "" {
cause = "the package failed with no test to name and no panic, signal or build error in its output"
}
return Failure{Package: name, Test: goTestNames(running), Output: Clip(lines, cause), Cause: cause}
}
var goFileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
// goErrorWords is the words of an error line of a test: trimmed, without the file and line go test put before them.
func goErrorWords(out string) string {
return strings.TrimSpace(goFileAndLine.ReplaceAllString(strings.TrimSpace(out), ""))
}
// finish moves what was read into the result: the packages in order, a failure for each failed package that has no
// failed test to name it.
func (r *goReader) finish() {
for _, name := range r.order {
p := r.pkgs[name]
if !p.Passed && !r.failedIn[name] {
r.res.Failed = append(r.res.Failed, r.packageFailure(name))
}
if !p.NoTests && p.Elapsed > r.res.Elapsed {
r.res.Elapsed = p.Elapsed
}
r.res.Packages = append(r.res.Packages, *p)
}
}
+114 -10
View File
@@ -3,10 +3,13 @@ package buildstep
import ( import (
"context" "context"
"fmt" "fmt"
"io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey"
) )
@@ -29,16 +32,25 @@ const Bubblewrap = "bwrap"
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step // - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
// downloaded) read-only; // downloaded) read-only;
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone // - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
// (WriteCaches), so a candidate's code never changes what a later build reads. // (WriteCaches), so a candidate's code never changes what a later build reads;
// - with a CacheLayer, what one command writes into the caches kept for the later commands of the same ask (issue 1921,
// the try speed-up): the vet, the unit tests and the integration compile of one ask compile each package once.
type Isolation struct { type Isolation struct {
Workspace string Workspace string
Writable []string Writable []string
ReadOnly []string ReadOnly []string
Caches []string Caches []string
WriteCaches bool WriteCaches bool
Env []string // CacheLayer is a directory of the ask's own, in its workspace and gone with it: each command writes the caches into
// a layer of its own beneath it (overlayfs refuses an upper layer two mounts share, and an ask's commands run side by
// side), which Isolated moves into the ask's kept layer when the command ends; every later command reads the kept
// layer above the shared cache. The shared cache stays unchanged. Empty: what a command writes is thrown away.
CacheLayer string
Env []string
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback. // Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
Hosts string Hosts string
// run is the directory of this command's own writable layers under CacheLayer; Isolated makes one per command.
run string
} }
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the // etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
@@ -91,7 +103,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
for _, group := range []struct { for _, group := range []struct {
what string what string
dirs []string dirs []string
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} { }{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}, {"cache layer", layerDirs(iso.CacheLayer)}} {
for _, d := range group.dirs { for _, d := range group.dirs {
if err := checkDir(group.what, d); err != nil { if err := checkDir(group.what, d); err != nil {
return nil, err return nil, err
@@ -113,7 +125,7 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
for _, d := range iso.ReadOnly { for _, d := range iso.ReadOnly {
argv = append(argv, "--ro-bind", d, d) argv = append(argv, "--ro-bind", d, d)
} }
argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...) argv = append(argv, iso.cacheArgs()...)
argv = append(argv, "--bind", iso.Workspace, iso.Workspace) argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
for _, d := range iso.Writable { for _, d := range iso.Writable {
argv = append(argv, "--bind", d, d) argv = append(argv, "--bind", d, d)
@@ -134,19 +146,107 @@ func (iso Isolation) command(env []string, name string, args ...string) ([]strin
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other // cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and // read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace). // nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace). With a
func cacheArgs(caches []string, write bool) []string { // CacheLayer the ask's kept layer lies above the shared cache and the command's writes go to its own layer on disk
// (--overlay), which Isolated keeps for the ask when the command ends.
func (iso Isolation) cacheArgs() []string {
var argv []string var argv []string
for _, c := range caches { for i, c := range iso.Caches {
if write { switch {
case iso.WriteCaches:
argv = append(argv, "--bind", c, c) argv = append(argv, "--bind", c, c)
continue case iso.CacheLayer != "":
upper, work := iso.runLayer(i)
argv = append(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay", upper, work, c)
default:
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
} }
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
} }
return argv return argv
} }
func layerDirs(layer string) []string {
if layer == "" {
return nil
}
return []string{layer}
}
// keptLayer is where the ask keeps what its commands wrote into the cache i.
func keptLayer(layer string, i int) string { return filepath.Join(layer, "kept", strconv.Itoa(i)) }
// runLayer is the upper and work directory of this command's writes into the cache i. Command, which makes no
// directory, names the layer "run"; Isolated gives each command one of its own.
func (iso Isolation) runLayer(i int) (upper, work string) {
run := iso.run
if run == "" {
run = filepath.Join(iso.CacheLayer, "run")
}
return filepath.Join(run, strconv.Itoa(i), "upper"), filepath.Join(run, strconv.Itoa(i), "work")
}
// layerLocks hold one mutex per cache layer, so two commands ending at once keep their writes one after the other.
var layerLocks sync.Map
// prepareLayer makes the kept layers and a run directory of the command's own under the cache layer.
func (iso *Isolation) prepareLayer() error {
if iso.CacheLayer == "" || iso.WriteCaches || len(iso.Caches) == 0 {
return nil
}
if err := os.MkdirAll(filepath.Join(iso.CacheLayer, "runs"), 0o700); err != nil {
return err
}
run, err := os.MkdirTemp(filepath.Join(iso.CacheLayer, "runs"), "run-")
if err != nil {
return err
}
iso.run = run
for i := range iso.Caches {
upper, work := iso.runLayer(i)
for _, d := range []string{keptLayer(iso.CacheLayer, i), upper, work} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
}
return nil
}
// keepLayer moves what the command wrote into the caches into the ask's kept layers, and removes its run directory.
// A file the command wrote replaces the kept one (a cache's newest write wins); a whiteout (a file the command
// deleted) is not kept, since a cache entry deleted by one command is no fault of a later one. A write that cannot be
// kept only means a later command builds it again.
func (iso Isolation) keepLayer() {
if iso.run == "" {
return
}
defer os.RemoveAll(iso.run)
mu, _ := layerLocks.LoadOrStore(iso.CacheLayer, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
for i := range iso.Caches {
upper, _ := iso.runLayer(i)
kept := keptLayer(iso.CacheLayer, i)
filepath.WalkDir(upper, func(p string, d fs.DirEntry, err error) error {
if err != nil || p == upper {
return nil
}
rel, _ := filepath.Rel(upper, p)
to := filepath.Join(kept, rel)
switch {
case d.IsDir():
if fi, err := os.Lstat(to); err == nil && !fi.IsDir() {
os.Remove(to)
}
os.MkdirAll(to, 0o700)
case d.Type().IsRegular():
os.Rename(p, to)
}
return nil
})
}
}
// loopbackHosts is the hosts file of a step: the loopback and nothing else. // loopbackHosts is the hosts file of a step: the loopback and nothing else.
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n" const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
@@ -166,6 +266,10 @@ func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
} }
iso := iso iso := iso
iso.Hosts = hosts iso.Hosts = hosts
if err := iso.prepareLayer(); err != nil {
return forgekey.Ran{Status: 1, Err: "the step's cache layer: " + err.Error()}
}
defer iso.keepLayer()
argv, err := iso.command(env, name, args...) argv, err := iso.command(env, name, args...)
if err != nil { if err != nil {
return forgekey.Ran{Status: 1, Err: err.Error()} return forgekey.Ran{Status: 1, Err: err.Error()}
+36
View File
@@ -64,3 +64,39 @@ func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err) t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
} }
} }
// 1921/S2: in one ask (one cache layer), a second go build of the same package does not compile it again, and the
// shared cache is unchanged afterwards.
func TestASecondBuildOfTheAskFindsTheFirstsCompiledAndTheSharedCacheIsUnchanged(t *testing.T) {
if _, err := exec.LookPath(Bubblewrap); err != nil {
t.Fatalf("bubblewrap is not installed here (issue 1309): %v", err)
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
shared := filepath.Join(root, "gobuild")
if err := os.MkdirAll(shared, 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(root, "ws")
tree := filepath.Join(workspace, "tree")
writeFile(t, tree, "go.mod", "module probe\n\ngo 1.22\n")
writeFile(t, tree, "probe.go", "package probe\n\nfunc Probe() int { return 42 }\n")
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{shared},
CacheLayer: filepath.Join(workspace, "cachelayer"), ReadOnly: []string{filepath.Dir(filepath.Dir(goBin))}})
env := []string{"PATH=" + filepath.Dir(goBin) + ":/usr/bin:/bin", "GOCACHE=" + shared, "GOTOOLCHAIN=local", "GOFLAGS=-mod=mod"}
for i, want := range []bool{true, false} {
r := run(context.Background(), env, "go", "-C", tree, "build", "-x", "./...")
if r.Status != 0 || r.Err != "" {
t.Fatalf("build %d: status %d %s\n%s", i+1, r.Status, r.Err, r.Stderr)
}
if compiled := strings.Contains(r.Stderr, "/compile "); compiled != want {
t.Errorf("build %d compiled the package: %v, want %v\n%s", i+1, compiled, want, r.Stderr)
}
}
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
t.Errorf("the shared cache was written: %d entries", len(entries))
}
}
+110
View File
@@ -0,0 +1,110 @@
package buildstep
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// overlayOf answers the upper directory a command line gives the cache c, "" when it gives none.
func overlayOf(argv []string, c string) string {
for i := 0; i+3 < len(argv); i++ {
if argv[i] == "--overlay" && argv[i+3] == c {
return argv[i+1]
}
}
return ""
}
// 1921/S2: with a cache layer, a command reads the ask's kept layer above the shared cache and writes into a layer on
// disk of its own, never into the shared cache and never into a throwaway tmpfs.
func TestACommandWithACacheLayerReadsTheKeptLayerAndWritesItsOwn(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/w", Caches: []string{"/cache/gomod", "/cache/gobuild"}, CacheLayer: "/w/cachelayer"}
argv, err := iso.Command("go", "vet")
if err != nil {
t.Fatal(err)
}
for i, c := range iso.Caches {
if !holds(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay") {
t.Errorf("the cache %s is not read beneath the ask's kept layer: %v", c, argv)
}
if up := overlayOf(argv, c); up == "" || filepath.Dir(filepath.Dir(filepath.Dir(up))) != "/w/cachelayer" {
t.Errorf("the cache %s is written into %q, not a layer of the ask's own: %v", c, up, argv)
}
if holds(argv, "--tmp-overlay", c) || holds(argv, "--bind", c, c) {
t.Errorf("the cache %s is thrown away or written: %v", c, argv)
}
}
if _, err := (Isolation{Workspace: "/w", CacheLayer: "cachelayer"}).Command("go"); err == nil {
t.Error("a relative cache layer was taken")
}
}
// 1921/S2: what one command wrote into the caches is kept for the ask's later commands, the shared cache stays
// unchanged, two commands at once write into layers of their own (overlayfs refuses an upper layer two mounts share),
// and a command's own layer is gone when it ends.
func TestWhatOneCommandCompiledIsReadByTheNextOfTheSameAsk(t *testing.T) {
fixedSystem(t)
root := t.TempDir()
shared := filepath.Join(root, "gobuild")
if err := os.MkdirAll(shared, 0o700); err != nil {
t.Fatal(err)
}
layer := filepath.Join(root, "ws", "cachelayer")
var mu sync.Mutex
uppers := map[string]bool{}
gate := make(chan struct{})
var started sync.WaitGroup
started.Add(2)
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
up := overlayOf(args, shared)
mu.Lock()
uppers[up] = true
mu.Unlock()
if holds(args, "--", "go", "build") {
started.Done()
<-gate // both commands are running before either ends
os.MkdirAll(filepath.Join(up, "ab"), 0o700)
os.WriteFile(filepath.Join(up, "ab", args[len(args)-1]+"-d"), []byte("compiled"), 0o600)
}
return forgekey.Ran{}
}
run := Isolated(inner, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})
var wg sync.WaitGroup
for _, pkg := range []string{"one", "two"} {
wg.Add(1)
go func() { defer wg.Done(); run(context.Background(), nil, "go", "build", pkg) }()
}
started.Wait()
close(gate)
wg.Wait()
if len(uppers) != 2 {
t.Fatalf("two commands at once wrote into %d layers, want 2 of their own: %v", len(uppers), uppers)
}
for _, pkg := range []string{"one", "two"} {
if b, err := os.ReadFile(filepath.Join(keptLayer(layer, 0), "ab", pkg+"-d")); err != nil || string(b) != "compiled" {
t.Errorf("what the build of %s compiled is not kept for the ask: %q %v", pkg, b, err)
}
}
for up := range uppers {
if _, err := os.Stat(up); !os.IsNotExist(err) {
t.Errorf("the command's own layer %s outlived it: %v", up, err)
}
}
var next []string
Isolated(func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
next = args
return forgekey.Ran{}
}, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})(context.Background(), nil, "go", "test")
if !holds(next, "--overlay-src", shared, "--overlay-src", keptLayer(layer, 0)) {
t.Errorf("the next command does not read the kept layer: %v", next)
}
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
t.Errorf("the shared cache was written: %v", entries)
}
}
+16 -6
View File
@@ -1,14 +1,24 @@
package buildstep package buildstep
import "git.novox.be/novox/mesh-sdk/go/catalogue" import "strings"
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as // LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says. // index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
const LauncherSuffix = catalogue.LauncherSuffix const LauncherSuffix = ".serve.mjs"
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root // Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
// (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share // every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
// (R4e, issue 1126), so the name a build writes is the name a reader expects. // language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller
// share (R4e), so the name a build writes is the name a reader expects.
func Launchers(language string, entrypoints []string) map[string]string { func Launchers(language string, entrypoints []string) map[string]string {
return catalogue.Launchers(language, entrypoints) out := map[string]string{}
if language != "typescript" {
return out
}
for _, entry := range entrypoints {
if strings.HasSuffix(entry, ".js") {
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
}
}
return out
} }
+16 -20
View File
@@ -15,9 +15,6 @@ type MergeResult struct {
Base, Head, Merge string Base, Head, Merge string
Conflicts []string Conflicts []string
Changed []string Changed []string
// Programs are the changed files that are compiled programs (ExecutableKind of their first bytes, read in the workspace
// at the merge); never nil for a clean merge, so a caller tells none from a module that reports none (issue 1577).
Programs []Program
} }
// MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit // MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit
@@ -27,7 +24,8 @@ type MergeResult struct {
// signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed. // signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed.
// //
// A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and // A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and
// fetches base by its sha. A head that does not merge cleanly is an answer, not an error: the files in conflict, the // fetches base by its sha: from the mirror WithMirror names, when it holds base (UpdateMirror to base first), else
// from remote. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
// merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed // merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed
// paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace // paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace
// not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's // not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's
@@ -55,7 +53,7 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...) full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...) r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr)) return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
} }
return r.Stdout, nil return r.Stdout, nil
} }
@@ -70,12 +68,20 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
if err != nil { if err != nil {
return res, err return res, err
} }
fetch := []string{"fetch", "--no-tags", "origin", base} fetchFrom := func(source string) error {
if strings.TrimSpace(shallow) == "true" { fetch := []string{"fetch", "--no-tags", source, base}
fetch = []string{"fetch", "--no-tags", "--unshallow", "origin", base} if strings.TrimSpace(shallow) == "true" {
fetch = []string{"fetch", "--no-tags", "--unshallow", source, base}
}
_, err := git(fetch...)
return err
} }
if _, err := git(fetch...); err != nil { // With a mirror brought to the trunk commit, the history is deepened from it, a local fetch (issue 1921, try speed
return res, fmt.Errorf("the trunk commit %s: %w", base, err) // S6); a mirror that cannot answer leaves it to the forge, as before.
if o.mirror == "" || fetchFrom(o.mirror) != nil {
if err := fetchFrom("origin"); err != nil {
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
}
} }
if _, err := git("checkout", "-q", "--detach", base); err != nil { if _, err := git("checkout", "-q", "--detach", base); err != nil {
return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err) return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err)
@@ -101,16 +107,6 @@ func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, comm
return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err) return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err)
} }
res.Changed = strings.Fields(changed) res.Changed = strings.Fields(changed)
raw, err := git("diff", "--raw", "-z", "--no-renames", "--diff-filter=AM", base, res.Merge)
if err != nil {
return res, fmt.Errorf("the files the merge adds or modifies cannot be read: %w", err)
}
res.Programs = programsIn(dir, raw)
history, err := historyPrograms(git, base, commit, res.Programs)
if err != nil {
return res, fmt.Errorf("the commits of the branch cannot be read for compiled programs: %w", err)
}
res.Programs = append(res.Programs, history...)
return res, nil return res, nil
} }
+33 -30
View File
@@ -79,30 +79,6 @@ func (r *mergeRig) head(files map[string]string) string {
return h return h
} }
// headThatAddsAndRemoves is head with an earlier commit on the branch that adds a program and a later one that removes it: the
// branch's tree is clean, its history is not. It answers the head and the earlier commit.
func (r *mergeRig) headThatAddsAndRemoves(files map[string]string) (head, added string) {
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
writeFile(r.t, r.work, "gone/prog", "\x7fELF\x02\x01")
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "oops")
added = realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "rm", "-q", "gone/prog")
return r.head2(files), added
}
// head2 commits files on the current branch and pushes it as the pull request's head.
func (r *mergeRig) head2(files map[string]string) string {
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "head")
h := realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
return h
}
// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha. // advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha.
func (r *mergeRig) advance(files map[string]string) string { func (r *mergeRig) advance(files map[string]string) string {
realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork) realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork)
@@ -126,7 +102,7 @@ func (r *mergeRig) fetch(head string) {
// holds both sides' changes. // holds both sides' changes.
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) { func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
r := newMergeRig(t) r := newMergeRig(t)
head, added := r.headThatAddsAndRemoves(map[string]string{"b.txt": "head\n", "prog": "\x7fELF\x02\x01"}) head := r.head(map[string]string{"b.txt": "head\n"})
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"}) base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
r.fetch(head) r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote()) res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
@@ -136,11 +112,8 @@ func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.
if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 { if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 {
t.Fatalf("result = %+v", res) t.Fatalf("result = %+v", res)
} }
if !reflect.DeepEqual(res.Changed, []string{"b.txt", "prog"}) { if !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt, prog", res.Changed) t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt", res.Changed)
}
if !reflect.DeepEqual(res.Programs, []Program{{"prog", "ELF", ""}, {"gone/prog", "ELF", added}}) { // issue 1577
t.Errorf("programs = %v, want the ELF the head adds and the one an earlier commit added and a later one removed", res.Programs)
} }
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge { if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge {
t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge) t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge)
@@ -197,3 +170,33 @@ func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) {
t.Errorf("result = %+v, %v", res, err) t.Errorf("result = %+v, %v", res, err)
} }
} }
// 1921/S6: with a mirror brought to the head and to the trunk commit, the merge asks the forge for nothing: it succeeds
// with the forge gone.
func TestMergeIntoWithAMirrorAtTheTrunkNeedsNoForge(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"b.txt": "head\n"})
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
mirror := filepath.Join(t.TempDir(), "mirror.git")
if err := os.MkdirAll(mirror, 0o755); err != nil {
t.Fatal(err)
}
for _, c := range []string{head, base} {
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, r.remote, c, "", AllowFileRemote()); err != nil {
t.Fatal(err)
}
}
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(r.bare); err != nil { // the forge is gone
t.Fatal(err)
}
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote(), WithMirror(mirror))
if err != nil {
t.Fatalf("the merge asked the forge: %v", err)
}
if len(res.Merge) != 40 || !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
t.Errorf("result = %+v", res)
}
}
+36
View File
@@ -72,3 +72,39 @@ func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) {
} }
} }
} }
// 1921/S6: with a mirror that holds the trunk commit, the merge deepens the workspace from the mirror, a local fetch,
// and asks the forge nothing; a mirror that cannot answer falls back to the forge.
func TestMergeIntoWithAMirrorFetchesTheTrunkFromTheMirror(t *testing.T) {
base := strings.Repeat("b", 40)
mirror := t.TempDir()
answer := func(failMirror bool) *fakeGit {
return &fakeGit{rule: func(l string) forgekey.Ran {
switch {
case strings.HasSuffix(l, "rev-parse HEAD"):
return forgekey.Ran{Stdout: sha + "\n"}
case strings.Contains(l, "--is-shallow-repository"):
return forgekey.Ran{Stdout: "true\n"}
case failMirror && strings.Contains(l, " fetch ") && strings.Contains(l, mirror):
return forgekey.Ran{Status: 128, Stderr: "fatal: not our ref"}
}
return forgekey.Ran{}
}}
}
f := answer(false)
dir := t.TempDir()
if _, err := MergeInto(context.Background(), f.run, dir, "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
if !strings.Contains(all, "fetch --no-tags --unshallow "+mirror+" "+base) || strings.Contains(all, "--unshallow origin") {
t.Errorf("the trunk was not fetched from the mirror alone:\n%s", all)
}
g := answer(true)
if _, err := MergeInto(context.Background(), g.run, t.TempDir(), "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if all := strings.Join(g.calls, "\n"); !strings.Contains(all, "fetch --no-tags --unshallow origin "+base) {
t.Errorf("a mirror that could not answer did not fall back to the forge:\n%s", all)
}
}
-18
View File
@@ -104,21 +104,3 @@ func TestUpdateMirrorRefusesBeforeRunning(t *testing.T) {
t.Errorf("ran %v", f.calls) t.Errorf("ran %v", f.calls)
} }
} }
// The forge's refusal reaches the reason in git's own words: the mirror of a repository its account cannot read said
// only "git -C: error:" (2026-10-26), since git writes a bare "error:" line before the forge's message.
func TestUpdateMirrorSaysTheForgesRefusal(t *testing.T) {
f := &fakeGit{}
f.rule = func(args string) (r forgekey.Ran) {
if strings.Contains(args, "fetch") {
r.Status = 128
r.Stderr = "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.\nerror: \nfatal: Could not read from remote repository.\n"
}
return
}
err := UpdateMirror(context.Background(), f.run, filepath.Join(t.TempDir(), "m.git"), "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), "nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.") ||
!strings.Contains(err.Error(), "Could not read from remote repository") || !strings.HasPrefix(err.Error(), "git fetch: ") {
t.Errorf("err = %v", err)
}
}
-67
View File
@@ -1,67 +0,0 @@
package buildstep
import (
"context"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on the workstation ran 30 minutes
// and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The
// puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they
// move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its
// time since the start, and written to the ask's running step key (delivery.Step.Phase), so its asker and the delivery
// view read where it stands from the bus.
type phaseKey struct{}
// Phase says that the ask whose run ctx belongs to entered phase; nothing outside an ask's run (a tool called by hand).
func Phase(ctx context.Context, phase string) {
if r, ok := ctx.Value(phaseKey{}).(*phases); ok && r != nil {
r.enter(strings.TrimSpace(phase))
}
}
// phases is one running ask's reporter: it writes the step key again with each new phase until the step ends.
type phases struct {
mu sync.Mutex
put Put
key string
step delivery.Step
ended bool
logf func(format string, args ...any)
prefix string
}
func (r *phases) enter(phase string) {
if phase == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.ended || phase == r.step.Phase {
return
}
at := now().UTC()
r.step.Phase, r.step.PhaseAt = phase, at
if r.logf != nil {
r.logf("%s: %s, %s after its start", r.prefix, phase, at.Sub(r.step.Started).Round(time.Second))
}
if r.put == nil {
return
}
if err := write(r.put, r.key, r.step); err != nil && r.logf != nil {
r.logf("%s: the phase %s could not be written to %s: %v", r.prefix, phase, r.key, err)
}
}
// end stops the writes (the step's end is written after it) and answers the step with its last phase, for the end.
func (r *phases) end() delivery.Step {
r.mu.Lock()
defer r.mu.Unlock()
r.ended = true
return r.step
}
-115
View File
@@ -1,115 +0,0 @@
package buildstep
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
)
// Program is a changed file whose first bytes are an executable binary: a compiled program, which is build output and not
// source (novox/hq issue 1577: `go build` in a module's folder writes one beside go.mod, and `git add -A` takes it).
type Program struct {
Path string `json:"path"`
Kind string `json:"kind"` // ELF, Mach-O or PE
// Commit is set for a program a commit of the branch added and a later one removed (or replaced): the merge's tree is clean,
// but the merge commit carries the blob into the trunk's history for good. Empty for a program the merge's tree holds.
Commit string `json:"commit,omitempty"`
}
// ExecutableKind says what executable format the first bytes of a file are, or "" when they are none. A PE needs its header
// ("PE\0\0" at the offset in bytes 0x3c) so a text starting with "MZ" is not one; a fat Mach-O (0xCAFEBABE) is told from a
// Java class by its architecture count (bytes 4 to 7, fewer than 0x20; a class has its version there).
func ExecutableKind(head []byte) string {
switch {
case bytes.HasPrefix(head, []byte("\x7fELF")):
return "ELF"
case bytes.HasPrefix(head, []byte("MZ")):
if len(head) >= 0x40 {
off := int(head[0x3c]) | int(head[0x3d])<<8 | int(head[0x3e])<<16 | int(head[0x3f])<<24
if off >= 0 && off+4 <= len(head) && bytes.Equal(head[off:off+4], []byte("PE\x00\x00")) {
return "PE"
}
}
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[4] == 0 && head[5] == 0 && head[6] == 0 && head[7] < 0x20:
return "Mach-O"
}
return ""
}
// programHeadSize is how many bytes of a file are read to tell its format: enough for a PE header at its usual offsets.
const programHeadSize = 0x100
// historyPrograms names the compiled programs that a commit of base..head added or modified and the merge's tree no longer
// holds at that path (those it holds are in final already): each with the commit that added it. git is the merge's git runner.
func historyPrograms(git func(args ...string) (string, error), base, head string, final []Program) ([]Program, error) {
revs, err := git("rev-list", "--no-merges", "--reverse", base+".."+head)
if err != nil {
return nil, err
}
held := map[string]bool{}
for _, p := range final {
held[p.Path] = true
}
out, seen := []Program{}, map[string]bool{}
for _, c := range strings.Fields(revs) {
raw, err := git("diff-tree", "-r", "-z", "--no-commit-id", "--no-renames", "--root", "--diff-filter=AM", c)
if err != nil {
return nil, err
}
f := strings.Split(raw, "\x00")
for i := 0; i+1 < len(f); i += 2 {
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
if len(meta) < 4 || (meta[1] != "100644" && meta[1] != "100755") || held[f[i+1]] || seen[meta[3]] {
continue
}
seen[meta[3]] = true
blob, err := git("cat-file", "blob", meta[3])
if err != nil {
return nil, err
}
head := []byte(blob)
if len(head) > programHeadSize {
head = head[:programHeadSize]
}
if k := ExecutableKind(head); k != "" {
out = append(out, Program{Path: f[i+1], Kind: k, Commit: c})
}
}
}
return out, nil
}
// programsIn reads the output of `git diff --raw -z --no-renames --diff-filter=AM` (":<old mode> <new mode> <old sha> <new
// sha> <status>\0<path>\0" per file) and names each added or modified regular file of the tree dir that is a compiled
// program, from its first bytes read locally. A symlink (mode 120000) and a submodule (160000) are not read. It never
// answers nil, so a caller can tell no programs from none reported.
func programsIn(dir, raw string) []Program {
out := []Program{}
f := strings.Split(raw, "\x00")
for i := 0; i+1 < len(f); i += 2 {
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
if len(meta) < 2 || (meta[1] != "100644" && meta[1] != "100755") {
continue
}
p := f[i+1]
full := filepath.Join(dir, filepath.FromSlash(p))
if st, err := os.Lstat(full); err != nil || !st.Mode().IsRegular() {
continue
}
fh, err := os.Open(full)
if err != nil {
continue
}
head := make([]byte, programHeadSize)
n, _ := io.ReadFull(fh, head)
fh.Close()
if k := ExecutableKind(head[:n]); k != "" {
out = append(out, Program{Path: p, Kind: k})
}
}
return out
}
-72
View File
@@ -1,72 +0,0 @@
package buildstep
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func pe(offset int, magic string) []byte {
b := make([]byte, 0x100)
copy(b, "MZ")
b[0x3c] = byte(offset)
copy(b[offset:], magic)
return b
}
func TestExecutableKindTellsProgramsFromText(t *testing.T) { // issue 1577
for _, c := range []struct {
name string
head []byte
want string
}{
{"elf", []byte("\x7fELF\x02\x01\x01"), "ELF"},
{"mach-o 64", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1}, "Mach-O"},
{"mach-o 32 big", []byte{0xfe, 0xed, 0xfa, 0xce, 0, 0, 0, 7}, "Mach-O"},
{"fat mach-o", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2}, "Mach-O"},
{"java class", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34}, ""},
{"pe", pe(0x80, "PE\x00\x00"), "PE"},
{"mz text", []byte("MZ is a text file starting with two letters"), ""},
{"mz without a pe header", pe(0x80, "XX\x00\x00"), ""},
{"text", []byte("package main"), ""},
{"empty", nil, ""},
} {
if got := ExecutableKind(c.head); got != c.want {
t.Errorf("%s: kind = %q, want %q", c.name, got, c.want)
}
}
}
func TestProgramsInNamesTheAddedAndModifiedRegularFilesThatArePrograms(t *testing.T) { // issue 1577
dir := t.TempDir()
write := func(name string, b []byte) {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), b, 0o755); err != nil {
t.Fatal(err)
}
}
write("m/prog", []byte("\x7fELF\x02\x01"))
write("m/notes", []byte("plain text"))
write("m/mac", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1})
if err := os.Symlink("prog", filepath.Join(dir, "m/link")); err != nil {
t.Fatal(err)
}
line := func(mode, path string) string { return ":000000 " + mode + " 0000 1111 A\x00" + path + "\x00" }
for _, c := range []struct {
name, raw string
want []Program
}{
{"none", "", []Program{}},
{"a program and a text", line("100755", "m/prog") + line("100644", "m/notes"), []Program{{"m/prog", "ELF", ""}}},
{"two programs", line("100644", "m/prog") + line("100755", "m/mac"), []Program{{"m/prog", "ELF", ""}, {"m/mac", "Mach-O", ""}}},
{"a symlink and a submodule are not read", line("120000", "m/link") + line("160000", "m/sub"), []Program{}},
{"a path the tree lacks", line("100644", "m/gone"), []Program{}},
} {
if got := programsIn(dir, c.raw); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: programs = %v, want %v", c.name, got, c.want)
}
}
}
+13 -461
View File
@@ -6,11 +6,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"sort" "sort"
"strings"
"sync" "sync"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -66,44 +64,10 @@ type Puller struct {
PullWait time.Duration PullWait time.Duration
Heartbeat time.Duration Heartbeat time.Duration
Logf func(format string, args ...any) Logf func(format string, args ...any)
// Load reads one key of the state the node's pause is kept in (nil and no error when it is absent). When set, Pull // Load reads one key of the module's steps state (nil and no error when it is absent). When set, Pull reads the
// reads the node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a // node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with Put, so a pause outlives
// pause outlives a restart. // a restart.
Load func(key string) ([]byte, error) Load func(key string) ([]byte, error)
// PausePut writes the node's pause key: the module's pauses state (delivery.PausesState), which has no age, so a
// pause never ends by itself while the steps state drops its old keys. Put when nil.
PausePut Put
// WatchConfig, when set, follows the module's config value `capacity` (CapacitySetting) as mesh-config resolves it
// for this node, and wakes the puller on each change (watchCapacity): WatchConfigServed in a module, a test's own
// double. Until it hands a value the puller judges by Capacity.
WatchConfig func(key string, handle func(stdio.ProvisionChange) error) error
// CoresPerStep is what one ask of this module needs, read at every judgement (NodeCoresPerStep); with it the capacity
// judged by is bounded by the node's build slice: min(the configured capacity, floor(the quota's cores / CoresPerStep))
// (EffectiveCapacity). Nil bounds nothing by the CPU. Quota reads the quota (Quota when nil; a test's own).
CoresPerStep func() float64
Quota func() (percent int, from string)
// BuildHours is the node's build window, read at every judgement (NodeBuildHours); with WatchConfig set the value
// mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0:
// steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour.
BuildHours func() (*BuildWindow, string)
// Eligible says why this node cannot run the seat's asks at all now, and "" when it can: what the module proves of
// its machine and finds missing (mesh-build-image: /dev/net/tun cannot be opened, so buildah's network for a RUN line
// cannot be made; 2026-10-28 on ace). Read at every judgement, so it is cheap (a module answers its last look).
// While it says a reason the capacity judged by is 0, said with the reason: steps running finish, no new one is
// taken, and the asks go to a node that can run them instead of failing here. The module calls Wake when its answer
// changes. Nil is always eligible.
Eligible func() string
// Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is
// declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
// does not take the asker's next copy at once. Nil has nothing.
Has func(need string) bool
// SettingsFile is the module's settings file. With it set the puller wakes whenever the file is written (a file event,
// WatchSettings), and reads Capacity from it itself, keeping why it cannot be read (CapacityNote); a parked puller at
// capacity 0 therefore wakes when the node-engine refills the file, and a failed config follow is tried again then.
SettingsFile string
// watching, when set, is called each time the settings file's watch is (re)armed: a test waits on it, not on a sleep.
watching func()
mu sync.Mutex mu sync.Mutex
inHand int inHand int
@@ -111,30 +75,6 @@ type Puller struct {
paused bool paused bool
wake chan struct{} wake chan struct{}
once sync.Once once sync.Once
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
watched int
hasWatched bool
// capFollow and hoursFollow are the state of the two config follows: followed once WatchConfig took them, trying
// while a start is in flight; followFault is the last failure said, so each failure is said once.
capFollowed, capTrying, hoursFollowed, hoursTrying bool
followFault string
// fileFault is why the settings file's capacity cannot be read, "" when it can.
fileFault string
// capWhy is why the config watch's last value could not be read, "" when it could.
capWhy string
// hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place
// of BuildHours once hasHours.
hours *BuildWindow
hoursFault string
hasHours bool
// judged is the capacity said last (log and capacity.<node>), so a change is said once.
judged delivery.Capacity
hasJudged bool
capWritten bool
capFault string
// declined are the kinds whose worker handed an ask this node declined, left out of the pulls without a wait until
// the next pull's wait ends.
declined map[string]bool
} }
func (p *Puller) init() { func (p *Puller) init() {
@@ -173,11 +113,7 @@ func (p *Puller) setPaused(paused bool, by string) error {
if err != nil { if err != nil {
return err return err
} }
put := p.PausePut if _, err := p.Put(key, json.RawMessage(raw)); err != nil {
if put == nil {
put = p.Put
}
if _, err := put(key, json.RawMessage(raw)); err != nil {
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err) return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
} }
} }
@@ -250,166 +186,13 @@ func (p *Puller) Running() []RunningAsk {
// room says whether the puller may take one more ask now. // room says whether the puller may take one more ask now.
func (p *Puller) room() bool { func (p *Puller) room() bool {
ok, _, _ := p.roomNow() capacity := 0
return ok if p.Capacity != nil {
} capacity = p.Capacity()
// roomNow is room, with the capacity and the asks in hand it was judged on.
func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
capacity = p.CapacityDetail().Effective
p.mu.Lock()
defer p.mu.Unlock()
return !p.paused && p.inHand < capacity, capacity, p.inHand
}
// CapacityNote says when the puller takes nothing for want of a capacity: why, and whether it is a fault (a setting that
// cannot be read or is refused, so a person must look) and not a choice (a capacity set to 0). "" when it pulls.
func (p *Puller) CapacityNote() (why string, fault bool) {
c := p.CapacityDetail()
if c.Effective > 0 {
return "", false
} }
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.fileFault != "" && !p.hasWatched { return !p.paused && p.inHand < capacity
return "the " + p.Language + " build capacity of " + p.Node + " is 0 because it cannot be read: " + p.fileFault, true
}
return "the " + p.Language + " build capacity of " + p.Node + " is " + c.Why, p.hasWatched && p.capWhy != ""
}
// CapacityNow is the capacity the puller judges by (CapacityDetail's Effective).
func (p *Puller) CapacityNow() int {
return p.CapacityDetail().Effective
}
// CapacityDetail is the capacity the puller judges by now and why: the configured one (the config watch's last value,
// else Capacity), bounded by the node's build slice when CoresPerStep is set (EffectiveCapacity), its quota read now. A
// change from what it said last is logged and written to `capacity.<node>` of the pauses state (PausePut), so the
// delivery view says it too.
func (p *Puller) CapacityDetail() delivery.Capacity {
p.mu.Lock()
watched, has := p.watched, p.hasWatched
p.mu.Unlock()
configured, from := 0, "the settings file"
switch {
case has:
configured, from = watched, "mesh-config, as the config watch handed it"
case p.SettingsFile != "":
n, err := ReadCapacity(p.SettingsFile)
configured = n
p.mu.Lock()
p.fileFault = ""
if err != nil {
p.fileFault = err.Error()
}
p.mu.Unlock()
case p.Capacity != nil:
configured = p.Capacity()
}
var c delivery.Capacity
if p.CoresPerStep == nil {
c = delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: "not judged: the module names no cores a step needs",
Why: fmt.Sprintf("its configured capacity %d", configured)}
} else {
quota := p.Quota
if quota == nil {
quota = Quota
}
percent, qfrom := quota()
c = EffectiveCapacity(configured, percent, qfrom, p.CoresPerStep())
}
c.ConfiguredFrom = from
w, fault := p.window()
if fault != "" {
c.BuildHoursFault = fault
c.Why = fmt.Sprintf("%s; at any hour, because its build hours cannot be read: %s", c.Why, fault)
}
if w != nil {
at := now()
zone, _ := at.Zone()
c.BuildHours = w.String() + " " + zone
if !w.Open(at) && c.Effective > 0 {
c.OutsideHours = true
c.Why = fmt.Sprintf("0, outside its build hours %s until %s (otherwise %s)", c.BuildHours, w.NextEdge(at).Format("15:04"), c.Why)
c.Effective = 0
} else if c.Effective > 0 {
c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04"))
}
}
if p.Eligible != nil {
if why := p.Eligible(); why != "" {
c.Ineligible = why
c.Why = fmt.Sprintf("0, not eligible: %s (otherwise %s)", why, c.Why)
c.Effective = 0
}
}
p.sayCapacity(c)
return c
}
// window is the node's build window now (the config watch's last value, else BuildHours, else none), and why its value
// cannot be read when it cannot.
func (p *Puller) window() (*BuildWindow, string) {
p.mu.Lock()
w, fault, has := p.hours, p.hoursFault, p.hasHours
p.mu.Unlock()
if has {
return w, fault
}
if p.BuildHours != nil {
return p.BuildHours()
}
return nil, ""
}
// untilEdge is how long until the build window's next edge, and false when the node has no window.
func (p *Puller) untilEdge() (time.Duration, bool) {
w, _ := p.window()
if w == nil {
return 0, false
}
at := now()
return w.NextEdge(at).Sub(at), true
}
// sayCapacity logs c when it differs from what was said last, and writes it to the node's capacity key until a write
// lands (a failed write is tried again at the next judgement, its error said once).
func (p *Puller) sayCapacity(c delivery.Capacity) {
p.mu.Lock()
same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU &&
p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours &&
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault &&
p.judged.Ineligible == c.Ineligible
if !same {
p.judged, p.hasJudged, p.capWritten = c, true, false
}
write := !p.capWritten && p.PausePut != nil
p.mu.Unlock()
if !same {
p.logf("the %s build capacity of %s is %s (configured from %s)", p.Language, p.Node, c.Why, c.ConfiguredFrom)
}
if !write {
return
}
key, err := delivery.CapacityKey(p.Node)
if err != nil {
return
}
c.At = now()
raw, err := delivery.EncodeCapacity(c)
if err == nil {
_, err = p.PausePut(key, json.RawMessage(raw))
}
p.mu.Lock()
defer p.mu.Unlock()
if err == nil {
p.capWritten, p.capFault = true, ""
return
}
if err.Error() != p.capFault {
p.capFault = err.Error()
p.logf("the %s build capacity of %s could not be written to %s (tried again at the next judgement): %v", p.Language, p.Node, key, err)
}
} }
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream // Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
@@ -420,12 +203,8 @@ func (p *Puller) Pull(ctx context.Context) error {
return errors.New("buildstep: a puller needs its source, its steps' put and its run") return errors.New("buildstep: a puller needs its source, its steps' put and its run")
} }
if err := p.restore(); err != nil { if err := p.restore(); err != nil {
// An unreadable pause degrades to not paused, said once: a puller that stops silently is the worse failure, and a return err // pulling on a pause that cannot be read could build on a node paused on purpose
// node kept from building is held by its capacity (0), not by a pause.
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
} }
p.follow() // WatchConfigServed waits for Serve, which a module starts after its pull
go p.watchSettings(ctx)
wait := p.PullWait wait := p.PullWait
if wait <= 0 { if wait <= 0 {
wait = DefaultPullWait wait = DefaultPullWait
@@ -434,34 +213,15 @@ func (p *Puller) Pull(ctx context.Context) error {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
if ok, capacity, inHand := p.roomNow(); !ok { if !p.room() {
// Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every
// park logs once, so a puller that sits idle says why (issue 1887).
p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
var edge <-chan time.Time // the build window's next edge, when the node has one: one timer, not a poll
stop := func() bool { return false }
if d, ok := p.untilEdge(); ok {
t := time.NewTimer(d + time.Second)
edge, stop = t.C, t.Stop
}
select { select {
case <-ctx.Done(): case <-ctx.Done():
stop()
return ctx.Err() return ctx.Err()
case <-p.wake: case <-p.wake:
case <-edge:
} }
stop()
p.follow() // a follow that failed is tried again at the next event
_, capacity, inHand = p.roomNow()
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
continue continue
} }
pullWait := wait msg, err := p.next(ctx, wait)
if d, ok := p.untilEdge(); ok && d < pullWait {
pullWait = d // a pull does not wait past the window's close
}
msg, err := p.next(ctx, pullWait)
if err != nil { if err != nil {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() return ctx.Err()
@@ -471,7 +231,6 @@ func (p *Puller) Pull(ctx context.Context) error {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-p.wake: case <-p.wake:
p.follow()
case <-time.After(wait): case <-time.After(wait):
} }
continue continue
@@ -485,81 +244,17 @@ func (p *Puller) Pull(ctx context.Context) error {
// next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none // next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none
// waits, a pull of the delivery worker that waits for one. // waits, a pull of the delivery worker that waits for one.
//
// A kind whose worker handed an ask this node declined is left out until that wait ended (issue 890): the pull's own
// wait, no timer of its own.
func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) { func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) {
p.mu.Lock()
declined := p.declined
p.mu.Unlock()
for _, kind := range delivery.AskKinds { for _, kind := range delivery.AskKinds {
if declined[kind] {
continue
}
msg, err := p.Source.Next(ctx, kind, 0) msg, err := p.Source.Next(ctx, kind, 0)
if err != nil || msg != nil { if err != nil || msg != nil {
return withKind(msg, kind), err return withKind(msg, kind), err
} }
} }
msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait) msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait)
p.mu.Lock()
p.declined = nil
p.mu.Unlock()
return withKind(msg, delivery.AskDelivery), err return withKind(msg, delivery.AskDelivery), err
} }
// decline ends the step of an ask this node lacks a need of, refused as declined with what it lacks, and drops the
// message: the asker puts the ask again under a new id (issue 890). A message whose decline cannot be written is handed
// back, as one whose start cannot be.
func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
if err == nil {
why := Refuse("the ask %s needs %s, and %s lacks %s: it is left to a node that has it", ask.ID,
strings.Join(ask.Needs, ", "), p.Node, strings.Join(lacks, ", "))
step.Ended = now().UTC()
step.State, step.Reason = delivery.StepRefused, why.Error()
step.Result, err = json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: lacks})
if err == nil {
err = writeEnd(p.Put, key, step)
}
}
if err != nil {
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
p.logf("the ask %s is declined on %s: it needs %s, and this node lacks %s", ask.ID, p.Node, strings.Join(ask.Needs, ", "), strings.Join(lacks, ", "))
_ = msg.Term()
p.mu.Lock()
if p.declined == nil {
p.declined = map[string]bool{}
}
p.declined[msg.Kind] = true
p.mu.Unlock()
}
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
// alone until the next pull's wait.
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
step.Ended = now().UTC()
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
if err := writeEnd(p.Put, key, step); err != nil {
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
_ = msg.Term()
p.mu.Lock()
if p.declined == nil {
p.declined = map[string]bool{}
}
p.declined[msg.Kind] = true
p.mu.Unlock()
}
func withKind(msg *Pulled, kind string) *Pulled { func withKind(msg *Pulled, kind string) *Pulled {
if msg != nil { if msg != nil {
msg.Kind = kind msg.Kind = kind
@@ -580,11 +275,7 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) {
_ = msg.Term() _ = msg.Term()
return return
} }
if lacks := delivery.Lacks(ask.Needs, p.Has); len(lacks) > 0 { step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID)
p.decline(msg, ask, lacks)
return
}
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
if err != nil { if err != nil {
p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err) p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err)
_ = msg.Nak() _ = msg.Nak()
@@ -610,7 +301,6 @@ func (p *Puller) take(ctx context.Context, msg *Pulled) {
} }
p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started} p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started}
p.mu.Unlock() p.mu.Unlock()
p.logf("the %s ask %s starts on %s: %s", ask.Kind, ask.ID, p.Node, step.What)
go p.run(ctx, msg, ask, key, step) go p.run(ctx, msg, ask, key, step)
} }
@@ -641,17 +331,8 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
} }
} }
}() }()
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)} result, err := protect(ctx, func(ctx context.Context) (any, error) { return p.Run(ctx, ask) })
result, err := protect(ctx, func(ctx context.Context) (any, error) {
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
})
step = ph.end() // its end keeps the phase it ended in
close(done) close(done)
var dec *Declining
if errors.As(err, &dec) && ctx.Err() == nil {
p.declineRun(msg, ask, key, step, dec)
return
}
if ctx.Err() != nil { if ctx.Err() != nil {
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs. // The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
return return
@@ -663,136 +344,7 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
_ = msg.Nak() _ = msg.Nak()
return return
} }
outcome := "passed"
if err != nil {
outcome = "did not pass: " + err.Error()
if len(outcome) > 300 {
outcome = outcome[:300] + "..."
}
}
p.logf("the %s ask %s ended on %s after %s, in its phase %q; %s", ask.Kind, ask.ID, p.Node,
now().Sub(step.Started).Round(time.Second), step.Phase, outcome)
if aerr := msg.Ack(); aerr != nil { if aerr := msg.Ack(); aerr != nil {
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr) p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
} }
} }
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: the laptop's
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
// not wait for the node-engine to fill its settings file again. Each value handed is kept, used in place of Capacity,
// and wakes the puller. A watch that cannot start leaves the puller on Capacity, said once.
func (p *Puller) watchCapacity() {
if p.WatchConfig == nil {
return
}
p.mu.Lock()
if p.capFollowed || p.capTrying {
p.mu.Unlock()
return
}
p.capTrying = true
p.mu.Unlock()
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
n, why := p.capacityFrom(c)
p.mu.Lock()
before, had := p.watched, p.hasWatched
p.watched, p.hasWatched, p.capWhy = n, true, why
p.mu.Unlock()
switch {
case why != "":
p.logf("the %s puller on %s pulls nothing: %s", p.Language, p.Node, why)
case !had || before != n:
p.logf("the %s build capacity of %s is %d, as mesh-config resolved it", p.Language, p.Node, n)
}
p.Wake()
return nil
})
p.followed(&p.capFollowed, &p.capTrying, err, "build capacity")
}
// follow starts each config follow that is not running: at the start, and at every wake after (a file event, a resume, a
// step's end), so a follow that failed is tried again by the next event and not given up for good.
func (p *Puller) follow() {
go p.watchCapacity()
go p.watchHours()
}
// followed records the end of one follow's start. A failure is said once per distinct error, with that it is tried again.
func (p *Puller) followed(done, trying *bool, err error, what string) {
p.mu.Lock()
*trying = false
*done = err == nil
said := err != nil && err.Error() == p.followFault
if err != nil {
p.followFault = err.Error()
} else {
p.followFault = ""
}
p.mu.Unlock()
if err != nil && !said {
p.logf("the %s %s of %s cannot be followed on the bus, so it is read from the settings file; the follow is tried "+
"again at the next event: %v", p.Language, what, p.Node, err)
}
}
// watchHours follows the module's config value `build-hours` when WatchConfig and BuildHours are set, as watchCapacity
// follows `capacity`: each value handed is kept, used in place of BuildHours, and wakes the puller. A value that cannot
// be read is no window, said; a watch that cannot start leaves the puller on BuildHours, said once.
func (p *Puller) watchHours() {
if p.WatchConfig == nil || p.BuildHours == nil {
return
}
p.mu.Lock()
if p.hoursFollowed || p.hoursTrying {
p.mu.Unlock()
return
}
p.hoursTrying = true
p.mu.Unlock()
err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error {
setting := ""
var w *BuildWindow
var why string
switch {
case c.Refused != "":
why = "its build hours are no longer answered: " + c.Refused
case len(c.Value) > 0 && string(c.Value) != "null" && json.Unmarshal(c.Value, &setting) != nil:
why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value)
default:
var err error
if w, err = ParseBuildWindow(setting); err != nil {
why = err.Error()
}
}
p.mu.Lock()
p.hours, p.hoursFault, p.hasHours = w, why, true
p.mu.Unlock()
if why != "" {
p.logf("the %s puller on %s builds at any hour: %s", p.Language, p.Node, why)
}
p.Wake()
return nil
})
p.followed(&p.hoursFollowed, &p.hoursTrying, err, "build hours")
}
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
// pull before it serves, and a watch asked before that would be refused.
func WatchConfigServed(key string, handle func(stdio.ProvisionChange) error) error {
<-stdio.Served()
return stdio.WatchConfig(key, handle)
}
// capacityFrom is the node's capacity in one value the watch handed, and why it is 0 when it cannot be read.
func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
if c.Refused != "" {
return 0, "its capacity is no longer answered: " + c.Refused
}
n, err := capacityOf(c.Value)
if err != nil {
return 0, err.Error()
}
return n, ""
}
-39
View File
@@ -1,39 +0,0 @@
package buildstep
import (
"encoding/json"
"sync"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// configWatch is a double of stdio.WatchConfig: it hands the value now at the start, as the runtime does, and a test
// hands each change after with set.
type configWatch struct {
mu sync.Mutex
value string
handle func(stdio.ProvisionChange) error
asked string
}
func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error) error {
w.mu.Lock()
w.asked, w.handle = key, handle
value := w.value
w.mu.Unlock()
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)})
}
func (w *configWatch) set(t *testing.T, value string) {
t.Helper()
w.mu.Lock()
handle, key := w.handle, w.asked
w.mu.Unlock()
if handle == nil {
t.Fatal("the puller watches no config value")
}
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(value)}); err != nil {
t.Fatal(err)
}
}
+31 -264
View File
@@ -3,17 +3,11 @@ package buildstep
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/delivery"
) )
@@ -106,38 +100,20 @@ func TestPullTakesDeliveryBeforeCheck(t *testing.T) {
} }
// 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when // 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when
// one ends, with no timer. Issue 1806's follow-up: the 2 is the CPU's, a configured 3 bounded by a 12-core slice at 6 // one ends, with no timer.
// cores a step, said once in capacity.<node>; and issue 1890: a run's phase is written to its step key and logged with
// its ask id, and its end keeps the last phase.
func TestPullHoldsAtMostItsCapacity(t *testing.T) { func TestPullHoldsAtMostItsCapacity(t *testing.T) {
src, st := NewFakeAsks(), newSteps() src, st := NewFakeAsks(), newSteps()
asks := []delivery.StepAsk{}
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
a := testAsk(delivery.AskDelivery) _ = src.PublishAsk(testAsk(delivery.AskDelivery))
asks = append(asks, a)
_ = src.PublishAsk(a)
} }
release := make(chan struct{}) release := make(chan struct{})
var mu sync.Mutex var mu sync.Mutex
started := 0 started := 0
var said []string p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 },
capacities := map[string][]byte{}
quota := 1200
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 3 },
CoresPerStep: func() float64 { return 6 },
Quota: func() (int, string) { mu.Lock(); defer mu.Unlock(); return quota, "a test's slice" },
PausePut: func(key string, v any) (uint64, error) {
mu.Lock()
capacities[key] = v.(json.RawMessage)
mu.Unlock()
return 1, nil
},
Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock() mu.Lock()
started++ started++
mu.Unlock() mu.Unlock()
Phase(ctx, "unit: tests of 3 packages")
<-release <-release
return nil, nil return nil, nil
}} }}
@@ -149,101 +125,30 @@ func TestPullHoldsAtMostItsCapacity(t *testing.T) {
if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 { if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 {
t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery)) t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery))
} }
c := p.CapacityDetail()
if c.Effective != 2 || c.Configured != 3 || c.ByCPU != 2 || c.QuotaCores != 12 || !strings.Contains(c.Why, "bounded by the CPU") {
t.Fatalf("the capacity judged by = %+v, want 2 of a configured 3 bounded by 12 cores / 6", c)
}
mu.Lock()
raw := capacities["capacity.shanks"]
capLines := 0
for _, l := range said {
if strings.Contains(l, "build capacity of shanks is 2, bounded by the CPU") {
capLines++
}
}
mu.Unlock()
if got, err := delivery.DecodeCapacity(raw); err != nil || got.Effective != 2 || capLines != 1 {
t.Fatalf("capacity.shanks = %s (%v), said %d times; want effective 2, said once", raw, err, capLines)
}
waitFor(t, "a phase on the step key", func() bool {
key, _ := delivery.AskStepKey("shanks", p.Running()[0].ID)
s, _ := st.get(key)
return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero() && s.Owner == Owner // 1890/AC5
})
release <- struct{}{} release <- struct{}{}
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 }) waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
// Taken from the source comes a moment before its run starts: wait for the start, not only the take. // Taken from the source comes a moment before its run starts: wait for the start, not only the take.
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 }) waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
close(release) close(release)
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 }) waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
for _, a := range asks {
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" || s.Owner != Owner {
t.Errorf("the end of %s = %+v, want passed in its last phase", a.ID, s)
}
}
mu.Lock()
logged := strings.Join(said, "\n")
quota = 600 // the cap lowered: one step at 6 cores, followed at the next judgement
mu.Unlock()
if !strings.Contains(logged, "the delivery ask "+asks[0].ID+" on shanks: unit: tests of 3 packages") {
t.Errorf("no journal line names the ask's phase: %s", logged)
}
if c := p.CapacityDetail(); c.Effective != 1 {
t.Errorf("after the quota fell to 6 cores, capacity %+v, want 1", c)
}
} }
// An ask that is not one, or not this seat's, is dropped and runs nowhere. And issue 890/AC1: a check ask needing // An ask that is not one, or not this seat's, is dropped and runs nowhere.
// node-build-node is declined on a node that lacks it (its step refused as declined, naming what it lacks, the message
// dropped for the asker to put again), and runs on a node that has it.
func TestPullDropsWhatIsNoAsk(t *testing.T) { func TestPullDropsWhatIsNoAsk(t *testing.T) {
src, st := NewFakeAsks(), newSteps() src, st := NewFakeAsks(), newSteps()
src.Publish(delivery.AskDelivery, []byte(`{"v":1}`)) src.Publish(delivery.AskDelivery, []byte(`{"v":1}`))
other := testAsk(delivery.AskDelivery) other := testAsk(delivery.AskDelivery)
other.Language = delivery.AskNode other.Language = delivery.AskNode
_ = src.PublishAsk(other) _ = src.PublishAsk(other)
placed := testAsk(delivery.AskCheck)
placed.Needs = []string{delivery.NeedNodeBuildNode}
if err := src.PublishAsk(placed); err != nil {
t.Fatal(err)
}
ran := false ran := false
p := &Puller{Source: src, Language: delivery.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 }, p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }} PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
go p.Pull(ctx) go p.Pull(ctx)
waitFor(t, "three termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 3 }) waitFor(t, "two termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 2 })
if ran { if ran {
t.Fatal("a wrong or a declined ask ran") t.Fatal("a wrong ask ran")
}
key, _ := delivery.AskStepKey("ace", placed.ID)
s, _ := st.get(key)
if d, ok := delivery.DeclinedOf(s); !ok || len(d.Lacks) != 1 || d.Lacks[0] != delivery.NeedNodeBuildNode || !strings.Contains(s.Reason, "lacks node-build-node") {
t.Fatalf("the declined step = %+v", s)
}
has, hasSteps := NewFakeAsks(), newSteps()
_ = has.PublishAsk(placed)
q := &Puller{Source: has, Language: delivery.AskGo, Node: "g14", Put: hasSteps.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Has: func(n string) bool { return n == delivery.NeedNodeBuildNode },
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
go q.Pull(ctx)
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
sit, sitSteps := NewFakeAsks(), newSteps()
toolchain := testAsk(delivery.AskDelivery)
_ = sit.PublishAsk(toolchain)
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
}}
go s2.Pull(ctx)
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
ss, _ := sitSteps.get(sk)
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
t.Fatalf("the sitting-out step = %+v", ss)
} }
} }
@@ -270,150 +175,36 @@ func (k *kv) load(key string) ([]byte, error) {
return k.vals[key], nil return k.vals[key], nil
} }
// 1887/A2b, 1887/AC-zero, 1887/AC-wake: at capacity 0 the puller takes nothing; a capacity raised takes effect at the // 1887/A2b, 1887/AC-zero: at capacity 0 the puller takes nothing; a capacity raised takes effect at the next pull.
// next pull, or, with the config watch (g14 idle at capacity 2, 2026-10-25), at once when the value moves, with no
// pause, resume or step end to wake it; a refused value pulls nothing, and a watch that cannot start leaves the
// settings file's capacity.
func TestPullAtCapacityZeroTakesNothing(t *testing.T) { func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
t.Run("settings file", func(t *testing.T) { src, st := NewFakeAsks(), &kv{}
src, st := NewFakeAsks(), &kv{} _ = src.PublishAsk(testAsk(delivery.AskDelivery))
_ = src.PublishAsk(testAsk(delivery.AskDelivery)) var mu sync.Mutex
var mu sync.Mutex capacity := 0
capacity := 0 p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond, Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity }, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }} ctx, cancel := context.WithCancel(context.Background())
ctx, cancel := context.WithCancel(context.Background()) defer cancel()
defer cancel() go p.Pull(ctx)
go p.Pull(ctx) time.Sleep(50 * time.Millisecond)
time.Sleep(50 * time.Millisecond) if src.Waiting(delivery.AskDelivery) != 1 {
if src.Waiting(delivery.AskDelivery) != 1 { t.Fatal("a node at capacity 0 took an ask")
t.Fatal("a node at capacity 0 took an ask")
}
mu.Lock()
capacity = 1
mu.Unlock()
p.Wake()
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
})
t.Run("config watch wakes", func(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
w := &configWatch{value: "0"}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
Capacity: func() int { return 0 }, // the settings file, not refilled yet
WatchConfig: w.watch,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the watch of the capacity", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.handle != nil })
time.Sleep(30 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a node at capacity 0 took an ask")
}
if w.asked != CapacitySetting {
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
}
w.set(t, "2")
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
})
t.Run("settings refill wakes a parked puller and retries a failed follow", func(t *testing.T) {
// 2026-10-28: a replaced builder held the old "shanks=2,..." in its settings file, its config follow timed out, and
// the puller parked at 0 with nothing to wake it when the node-engine refilled the file.
src, st := NewFakeAsks(), newSteps()
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
file := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(file, []byte(`{"capacity":"shanks=2,ace=2"}`), 0o600); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
attempts := 0
watch := func(key string, handle func(stdio.ProvisionChange) error) error {
mu.Lock()
defer mu.Unlock()
attempts++
if attempts == 1 {
return errors.New("nats: timeout")
}
return nil // followed; no value handed yet
}
armed := make(chan struct{}, 8)
var parked atomic.Int32
parks := func() int32 { return parked.Load() }
logf := func(format string, args ...any) {
if strings.Contains(format, "parks") {
parked.Add(1)
}
}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, PullWait: 10 * time.Millisecond,
SettingsFile: file, WatchConfig: watch, Logf: logf, watching: func() { armed <- struct{}{} },
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the parked puller to say why", func() bool { why, fault := p.CapacityNote(); return fault && why != "" })
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a node whose capacity cannot be read took an ask")
}
<-armed // the file watch is in place
tmp := file + ".new"
if err := os.WriteFile(tmp, []byte(`{"capacity":2}`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, file); err != nil {
t.Fatal(err)
}
waitFor(t, "the ask taken once the file was refilled", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
// and the directory itself replaced (IN_IGNORED): the watch is armed again and a later refill still wakes the puller
dir := filepath.Dir(file)
before := parks()
if err := os.RemoveAll(dir); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
<-armed // armed again
waitFor(t, "the puller parked with no settings file", func() bool { return parks() > before })
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
if err := os.WriteFile(file, []byte(`{"capacity":2}`), 0o600); err != nil {
t.Fatal(err)
}
waitFor(t, "the second ask taken after the directory was replaced", func() bool { a, _, _ := src.Fates(); return len(a) == 2 })
waitFor(t, "the failed follow tried again", func() bool { mu.Lock(); defer mu.Unlock(); return attempts >= 2 })
})
for _, c := range []struct {
name string
watch func(string, func(stdio.ProvisionChange) error) error
room bool
}{
{"refused value leaves no room", func(key string, handle func(stdio.ProvisionChange) error) error {
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Refused: "the module is no longer on g14"})
}, false},
{"watch that cannot start keeps the settings file", func(string, func(stdio.ProvisionChange) error) error {
return errors.New("no runtime")
}, true},
} {
t.Run(c.name, func(t *testing.T) {
p := &Puller{Source: NewFakeAsks(), Language: delivery.AskGo, Node: "g14", Put: newSteps().put,
Capacity: func() int { return 1 }, WatchConfig: c.watch,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
p.watchCapacity()
if p.room() != c.room {
t.Fatalf("room = %v, want %v", p.room(), c.room)
}
})
} }
mu.Lock()
capacity = 1
mu.Unlock()
p.Wake()
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
} }
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state // 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
// stays paused until resumed. // stays paused until resumed.
func TestPullPauseOutlivesARestart(t *testing.T) { func TestPullPauseOutlivesARestart(t *testing.T) {
src, st, steps := NewFakeAsks(), &kv{}, &kv{} src, st := NewFakeAsks(), &kv{}
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil } run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run} Capacity: func() int { return 2 }, Run: run}
if err := p.Pause("the operator"); err != nil { if err := p.Pause("the operator"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -422,12 +213,9 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" { if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
t.Fatalf("the pause key holds %s (%v)", raw, err) t.Fatalf("the pause key holds %s (%v)", raw, err)
} }
if raw, _ := steps.load(key); raw != nil {
t.Fatalf("the pause was written in the steps state, whose keys age out: %s", raw)
}
// The restart: a new puller on the same state. // The restart: a new puller on the same state.
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load, again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run} Capacity: func() int { return 2 }, Run: run}
_ = src.PublishAsk(testAsk(delivery.AskDelivery)) _ = src.PublishAsk(testAsk(delivery.AskDelivery))
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -440,27 +228,6 @@ func TestPullPauseOutlivesARestart(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 }) waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
t.Run("a pause that cannot be read is said and pulled as not paused", func(t *testing.T) {
src := NewFakeAsks()
var said atomic.Value
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put,
Load: func(string) ([]byte, error) { return nil, errors.New("no runtime yet") },
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run,
Logf: func(f string, a ...any) {
if m := fmt.Sprintf(f, a...); strings.Contains(m, "cannot be read") {
said.Store(m)
}
}}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask taken", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
if m, _ := said.Load().(string); !strings.Contains(m, "pulling as not paused") {
t.Fatalf("the unreadable pause was not said: %q", m)
}
})
} }
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor // 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
-11
View File
@@ -16,14 +16,3 @@ func (r *Refusal) Error() string { return r.Text }
// Refuse is a Refusal with its text formatted. // Refuse is a Refusal with its text formatted.
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} } func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
// sits out and never fails a build.
type Declining struct {
Text string
Lacks []string // what this node lacks, in words the operator reads
}
func (d *Declining) Error() string { return d.Text }
-48
View File
@@ -2,12 +2,9 @@ package buildstep
import ( import (
"fmt" "fmt"
"maps"
"path" "path"
"regexp" "regexp"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
var ( var (
@@ -30,10 +27,6 @@ type FetchRequest struct {
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"` Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"` Paths []string `json:"paths,omitempty"`
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
// the repository names, checksum-verified, and names them in its log.
Packages delivery.PackageVersions `json:"packages,omitempty"`
} }
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. // FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
@@ -42,8 +35,6 @@ func FetchRequestProperties() map[string]any {
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
} }
} }
@@ -58,12 +49,6 @@ func (r FetchRequest) Args() map[string]any {
if len(r.Paths) > 0 { if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...) args["paths"] = append([]string(nil), r.Paths...)
} }
if len(r.Packages) > 0 {
args["packages"] = map[string]any{}
for p, v := range r.Packages {
args["packages"].(map[string]any)[p] = v
}
}
return args return args
} }
@@ -93,42 +78,9 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
default: default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings") return FetchRequest{}, fmt.Errorf("paths is a list of strings")
} }
packages, err := PackagesArg(args["packages"])
if err != nil {
return FetchRequest{}, err
}
r.Packages = packages
return r, r.Check() return r, r.Check()
} }
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
// nil for none.
func PackagesArg(v any) (delivery.PackageVersions, error) {
out := delivery.PackageVersions{}
switch v := v.(type) {
case nil:
return nil, nil
case delivery.PackageVersions:
maps.Copy(out, v)
case map[string]string:
maps.Copy(out, v)
case map[string]any:
for p, version := range v {
s, ok := version.(string)
if !ok {
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
out[p] = s
}
default:
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
if len(out) == 0 {
return nil, nil
}
return out, out.Check()
}
// Check is what ParseFetchRequest refuses. // Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error { func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
+2 -9
View File
@@ -10,15 +10,11 @@ import (
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 // from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
// clause 1). The request survives the trip through a tool call's arguments. // clause 1). The request survives the trip through a tool call's arguments.
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}} Dir: ".", Paths: []string{"package.json", "src"}}
args := req.Args() args := req.Args()
// A tool call's arguments arrive as JSON: lists of any, objects of any. // A tool call's arguments arrive as JSON: lists of any.
args["paths"] = []any{"package.json", "src"} args["paths"] = []any{"package.json", "src"}
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
}
got, err := ParseFetchRequest(args) got, err := ParseFetchRequest(args)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -46,9 +42,6 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, {map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
} { } {
args := map[string]any{} args := map[string]any{}
for k, v := range ok { for k, v := range ok {
+6 -5
View File
@@ -14,13 +14,14 @@ func SeatTools(seat, what string, p *Puller) []stdio.Tool {
Run: func(map[string]any) (any, error) { return run() }} Run: func(map[string]any) (any, error) { return run() }}
} }
current := func() (any, error) { current := func() (any, error) {
c := p.CapacityDetail() capacity := 0
return map[string]any{"node": p.Node, "capacity": c.Effective, "capacity_why": c.Why, "capacity_detail": c, if p.Capacity != nil {
"running": p.Running(), "paused": p.Paused()}, nil capacity = p.Capacity()
}
return map[string]any{"node": p.Node, "capacity": capacity, "running": p.Running(), "paused": p.Paused()}, nil
} }
return []stdio.Tool{ return []stdio.Tool{
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity (the configured one, "+ verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
"bounded by the cores its node's build slice gives over the cores a step needs) and why, "+
"and whether it is paused.", current), "and whether it is paused.", current),
verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+ verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+
"across a restart (the node's pause key).", func() (any, error) { "across a restart (the node's pause key).", func() (any, error) {
-365
View File
@@ -1,365 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A step left running** (novox/hq issue 1890, note 5). A build module that stops while a step runs leaves its key in
// state running for good: nothing else writes that key's end, and the stream's redelivery only rewrites it when the same
// ask comes back. At its start a module ends the steps its node left running by another process (EndStale: every step
// names its owner, delivery.Step.Owner; note 21), and its step-end tool ends one by hand (EndOne). The conformance kit's
// left-running case holds every build module to the start sweep (conformance.LeftRunning).
// RestartReason is why a step a previous process left running is ended.
const RestartReason = "the build module restarted while it ran"
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
// for this process's, which may still run it, so EndOne refuses it.
var Boot = time.Now()
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
var Owner = ownerOf(bootID, os.Getpid(), Boot)
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
var bootID = readBootID()
const noBootID = "no-boot-id"
func ownerOf(bootID string, pid int, start time.Time) string {
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
}
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
// apart).
func readBootID() string {
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
return id
}
return noBootID
}
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
type Scan struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, uint64, error)
Update func(key string, value any, revision uint64) (uint64, error)
Delete func(key string, revision uint64) error
}
// ScanOf is the Scan of a state the process keeps.
//
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
func ScanOf(s stdio.KeptState) Scan {
get := func(key string) (json.RawMessage, uint64, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, 0, err
}
return e.Value, e.Revision, nil
}
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
_, rev, err := get(key)
if err != nil {
return err
}
if rev != revision {
return stdio.ErrChanged
}
return s.Delete(key)
}}
}
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
// has passed (delivery.StepBound), when it is nobody's.
func isAsk(key string, s delivery.Step) bool {
_, _, err := delivery.ParseAskStepKey(key)
return err == nil && strings.HasSuffix(s.Tool, " ask")
}
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
// fingerprint.
func AskWhat(a delivery.StepAsk) string {
short := func(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
parts := []string{a.Kind + " ask"}
switch {
case a.Check != nil:
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
if a.Check.Run != "" {
parts = append(parts, "run "+a.Check.Run)
}
case a.Build != nil:
parts = append(parts, "module "+a.Build.Module)
if r := str(a.Build.Fetch, "repository"); r != "" {
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
}
if a.Build.FP != "" {
parts = append(parts, "fp "+short(a.Build.FP))
}
case a.Compose != nil:
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
}
return strings.Join(parts, ", ")
}
// endRunning writes step ended failed with why, only while its key is still at revision.
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = scan.Update(key, json.RawMessage(raw), revision)
return err
}
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
// of node are read; a value that is no step is left alone.
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
// write its end (issue 1890, note 21).
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
// held one of a build node's two slots).
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
//
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
// (a redelivered ask's start, a real end) is skipped.
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
keys, err := scan.Keys()
if err != nil {
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
}
var ended []string
for _, k := range keys {
n, _, perr := delivery.ParseStepKey(k)
if perr != nil || n != node {
continue
}
raw, rev, err := scan.Get(k)
if err != nil {
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
}
if len(raw) == 0 {
continue
}
step, err := delivery.DecodeStep(raw)
if err != nil || step.State != delivery.StepRunning {
continue
}
why, said := staleWhy(k, step, before)
if why == "" {
continue
}
done := "ended"
if step.Owner != "" && isAsk(k, step) {
done = "deleted, left to the stream's redelivery"
if scan.Delete == nil {
err = fmt.Errorf("the steps state takes no delete")
} else {
err = scan.Delete(k, rev)
}
} else {
err = endRunning(scan, k, step, rev, why)
}
if err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
}
if logf != nil {
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
step.Started.UTC().Format(time.RFC3339), done, why, said)
}
ended = append(ended, step.ID)
}
return ended, nil
}
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
// its owner, or "" to leave it.
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
switch {
case step.Owner == Owner:
return "", ""
case step.Owner != "":
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
case isAsk(key, step):
if !step.Started.Add(delivery.StepBound).Before(now()) {
return "", ""
}
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
"it names no owner (an older build wrote it)"
case step.Started.Before(before):
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
}
return "", ""
}
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
id = strings.TrimSpace(id)
if n, rest, ok := strings.Cut(id, "."); ok {
if n != node {
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
}
id = rest
}
if strings.TrimSpace(why) == "" {
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
}
key, err := delivery.StepKey(node, id)
if err != nil {
return delivery.Step{}, err
}
raw, rev, err := scan.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
step, err := delivery.DecodeStep(raw)
if err != nil {
return delivery.Step{}, err
}
if step.State != delivery.StepRunning {
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
}
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
}
if err := endRunning(scan, key, step, rev, why); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
}
return delivery.Step{}, err
}
step.State, step.Reason = delivery.StepFailed, why
return step, nil
}
// StepEndTool is the module's step-end tool, named name (build_go_step_end): it ends one step left running, with a
// reason, refusing one this process started. p may be nil.
func StepEndTool(name string, node string, scan Scan, p *Puller) stdio.Tool {
return stdio.Tool{Name: name,
Description: "End one step of this machine that is stuck in state running (for instance left by a restart of the module) " +
"as failed, with the reason given; its asker then sees it over, so ending an ask still in its stream fails its asker. " +
"A step not running, or one this process started, is refused. " +
"id is the step id or the key <node>.<id>.",
Input: map[string]any{"type": "object", "required": []string{"id", "why"}, "properties": map[string]any{
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"},
"why": map[string]any{"type": "string", "description": "why it is ended; it becomes the step's reason"}}},
Run: func(args map[string]any) (any, error) {
id, _ := args["id"].(string)
why, _ := args["why"].(string)
var live func(string) bool
if p != nil {
live = func(id string) bool {
for _, r := range p.Running() {
if r.ID == id {
return true
}
}
return false
}
}
return EndOne(scan, node, id, why, live)
}}
}
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
var (
StaleAttempts = 6
StaleRetry = 5 * time.Second
)
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
// start sweep in its test.
var StaleServed = stdio.Served
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
// says so once: Owner then tells processes apart by their pid and start only.
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
select {
case <-ctx.Done():
return
case <-StaleServed():
}
if bootID == noBootID && logf != nil {
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
}
for attempt := 1; ; attempt++ {
ended, err := EndStale(scan, node, before, logf)
if err == nil {
if logf != nil {
logf("%d steps of %s left running by a previous process ended", len(ended), node)
}
return
}
if logf != nil {
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
}
if attempt >= StaleAttempts {
return
}
select {
case <-ctx.Done():
return
case <-time.After(StaleRetry):
}
}
}
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
// process left and the asks past delivery.StepBound.
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
select {
case <-stdio.Served():
default:
return
}
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
logf("ending the steps left running: %v", err)
}
}
-161
View File
@@ -1,161 +0,0 @@
package buildstep
import (
"encoding/json"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
func scanOf(k *keys, race func(key string)) Scan {
return Scan{
Keys: func() ([]string, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []string
for key := range k.puts {
out = append(out, key)
}
return out, nil
},
Get: func(key string) (json.RawMessage, uint64, error) {
k.mu.Lock()
defer k.mu.Unlock()
s, ok := k.puts[key]
if !ok {
return nil, 0, nil
}
raw, err := delivery.EncodeStep(s[len(s)-1])
return raw, uint64(len(s)), err
},
Delete: func(key string, revision uint64) error {
k.mu.Lock()
defer k.mu.Unlock()
if uint64(len(k.puts[key])) != revision {
return stdio.ErrChanged
}
delete(k.puts, key)
return nil
},
Update: func(key string, value any, revision uint64) (uint64, error) {
if race != nil {
race(key)
}
k.mu.Lock()
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if n != revision {
return 0, stdio.ErrChanged
}
return k.put(key, value)
},
}
}
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
// meanwhile. EndOne ends one by hand, never one this process owns or started.
func TestEndStaleAndEndOne(t *testing.T) {
k := newKeys()
boot := time.Now()
oldBoot := Boot
Boot = boot
defer func() { Boot = oldBoot }()
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
put := func(node, id, tool, state string, started time.Time, owner string) {
key, _ := delivery.StepKey(node, id)
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
}
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
// raced1 is ended by its own run between the read and the write.
raced := false
scan := scanOf(k, func(key string) {
if key == "ace.raced1" && !raced {
raced = true
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
}
})
var said []string
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
if err != nil || len(ended) != 4 {
t.Fatalf("ended %v, %v", ended, err)
}
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
}
for _, key := range []string{"ace.old1", "ace.gone2"} {
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
t.Errorf("%s = %+v", key, s)
}
}
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
t.Errorf("the old ask = %+v", s)
}
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
}
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
if s := k.last(key); s.State != state {
t.Errorf("%s = %s, want %s", key, s.State, state)
}
}
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
// another node's; ends a step a previous process left.
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
t.Errorf("ended %s with why %q", c.id, c.why)
}
}
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
t.Error("ended a held step")
}
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
t.Fatal(err)
}
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
t.Errorf("old2 = %+v", s)
}
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
t.Errorf("two starts of one pid share the owner %q", a)
}
}
func mustStep(t *testing.T, s delivery.Step) []byte {
t.Helper()
raw, err := delivery.EncodeStep(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
func TestAskWhat(t *testing.T) {
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
t.Errorf("check = %q", got)
}
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
t.Errorf("build = %q", got)
}
}
+5 -6
View File
@@ -1,7 +1,6 @@
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once // Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard // and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers // (issue 1473) and the fetch of one commit over SSH (issue 1474).
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
package buildstep package buildstep
import ( import (
@@ -44,7 +43,7 @@ func NewID() string {
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and // the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
// the process goes on. If the running write fails, nothing was started and Start answers the error. // the process goes on. If the running write fails, nothing was started and Start answers the error.
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) { func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
step, key, err := begin(put, node, tool, NewID(), "") step, key, err := begin(put, node, tool, NewID())
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -52,9 +51,9 @@ func Start(ctx context.Context, put Put, node, tool string, run func(ctx context
return step.ID, nil return step.ID, nil
} }
// begin writes the step running under id, owned by this process (Owner). // begin writes the step running under id.
func begin(put Put, node, tool, id, what string) (delivery.Step, string, error) { func begin(put Put, node, tool, id string) (delivery.Step, string, error) {
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what, Owner: Owner} step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
key, err := delivery.StepKey(node, step.ID) key, err := delivery.StepKey(node, step.ID)
if err != nil { if err != nil {
return delivery.Step{}, "", err return delivery.Step{}, "", err
+3 -4
View File
@@ -70,9 +70,8 @@ func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
t.Errorf("Start took %v", d) t.Errorf("Start took %v", d)
} }
s := k.last("ace." + id) s := k.last("ace." + id)
// 1890/AC5: the running key names this process as its owner. if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() || s.Owner != Owner || Owner == "" { t.Errorf("step = %+v", s)
t.Errorf("step = %+v, want owner %q", s, Owner)
} }
} }
@@ -101,7 +100,7 @@ func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
t.Fatalf("ended key %q", got) t.Fatalf("ended key %q", got)
} }
s := k.last("ace." + id) s := k.last("ace." + id)
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() || s.Owner != Owner { if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
t.Errorf("step = %+v", s) t.Errorf("step = %+v", s)
} }
}) })
-192
View File
@@ -1,192 +0,0 @@
package buildstep
// The reader of `node --test` under its TAP reporter (issue 1901's thorough fix; first written in mesh-build-node by
// fix-1901ts): each failing test with its name, its file, its cause and its own block, never a passing line of an
// earlier file, which is what node's default reporter left the first fifteen lines of.
import (
"regexp"
"strings"
)
// NodeTestOptions is the NODE_OPTIONS a build module runs a package's test script with: node's TAP reporter, written to
// the file dest, so whatever the script prints on its own output the report is whole and machine-readable. A script
// that names a reporter of its own makes node refuse the run loudly ("--test-reporter must match the number of
// --test-reporter-destination"), never quietly.
func NodeTestOptions(dest string) string {
return "--test-reporter=tap --test-reporter-destination=" + dest
}
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
// ReadTAP reads the failing tests of a `node --test` run: report is what the TAP reporter wrote (NodeTestOptions'
// file), console the script's own stdout and stderr, script the name of the script that ran and dir the package's
// directory, which a test's file is made relative to. A suite that failed only because a test inside it did is not
// reported twice. A test file that did not load (a syntax error, a crash, an error after its test ended) takes its
// cause from the lines node wrote before it. When the report holds no failing test (node itself failed, or the script
// failed before its tests ran: a build, a reporter refused) the failure is the console's (ConsoleFailure).
func ReadTAP(pkg, script, dir, report, console string) []Failure {
var failed []Failure
all := strings.Split(stripANSI(strings.ReplaceAll(report, "\r\n", "\n")), "\n")
var cancelled []Failure // failed because a suite around them did: said after the failure that carries the cause
prev := 0 // the line after the last test block, passing or not: what the run wrote since belongs to the next test
for i := 0; i < len(all); i++ {
name, ok := tapNotOk(all[i])
if !ok {
if tapOk(all[i]) {
i = tapBlockEnd(all, i) - 1
prev = i + 1
}
continue
}
indent := len(all[i]) - len(strings.TrimLeft(all[i], " "))
block := []string{strings.TrimSpace(all[i])}
fields := map[string]string{}
var key string
inYAML := false
j := i + 1
for ; j < len(all); j++ {
t := strings.TrimSpace(all[j])
if !inYAML {
if t == "---" {
inYAML = true
continue
}
break
}
if t == "..." {
j++
break
}
block = append(block, strings.TrimRight(all[j], " \t"))
if k, v, found := tapKey(all[j], indent); found {
key = k
if v != "|-" && v != "|" {
fields[k] = strings.Trim(v, "'")
}
} else if key != "" && fields[key] == "" && t != "" {
fields[key] = t // the first line of a block scalar
}
}
from, at := prev, i
i, prev = j-1, j
if fields["failureType"] == "subtestsFailed" {
continue
}
cause := fields["error"]
if cause == "test failed" || cause == "" {
// a file that did not load, or an error after a test ended: node says only "test failed" and wrote the
// error as comment lines before it
var own []string
for _, l := range all[from:at] {
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") {
own = append(own, strings.TrimPrefix(t, "# "))
}
}
for _, l := range own {
if strings.Contains(l, "Error") {
cause = l
break
}
}
if cause == "" && len(own) > 0 {
cause = own[0]
}
block = append(own, block...)
}
if cause == "" {
cause = fields["failureType"]
}
if cause == "" {
cause = "the test failed and node said no error"
}
cause = CutLine(cause)
file := strings.TrimPrefix(fields["location"], "file://")
if dir != "" {
file = strings.TrimPrefix(file, strings.TrimSuffix(dir, "/")+"/")
}
f := Failure{Package: pkg, Test: name, File: file, Cause: cause, Output: Clip(block, cause)}
if fields["failureType"] == "cancelledByParent" {
cancelled = append(cancelled, f)
continue
}
failed = append(failed, f)
}
if failed = append(failed, cancelled...); len(failed) > 0 {
return failed
}
var comments []string
for _, l := range all {
if t := strings.TrimSpace(l); strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "# Subtest") && !tapCount(t) {
comments = append(comments, strings.TrimPrefix(t, "# "))
}
}
return []Failure{ConsoleFailure(pkg, script, strings.Join(comments, "\n")+"\n"+console)}
}
// tapCount says a comment is TAP's closing count ("# tests 3", "# pass 2", "# duration_ms 1.2").
func tapCount(t string) bool {
for _, w := range []string{"tests", "suites", "pass", "fail", "cancelled", "skipped", "todo", "duration_ms"} {
if strings.HasPrefix(t, "# "+w+" ") {
return true
}
}
return false
}
// tapOk says a line is a passing test's "ok N - name", indented or not.
func tapOk(l string) bool {
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
}
// tapBlockEnd is the line after the test line at i and its YAML block ("---" to "...").
func tapBlockEnd(all []string, i int) int {
j := i + 1
if j >= len(all) || strings.TrimSpace(all[j]) != "---" {
return j
}
for j++; j < len(all); j++ {
if strings.TrimSpace(all[j]) == "..." {
return j + 1
}
}
return j
}
// tapNotOk is the test name of a "not ok N - name" line, indented or not; a TODO or SKIP directive fails nothing.
func tapNotOk(l string) (string, bool) {
t := strings.TrimSpace(l)
rest, ok := strings.CutPrefix(t, "not ok ")
if !ok {
return "", false
}
if u := strings.ToUpper(rest); strings.Contains(u, " # TODO") || strings.Contains(u, " # SKIP") {
return "", false
}
if _, after, found := strings.Cut(rest, " - "); found {
rest = after
}
return strings.TrimSpace(rest), true
}
// tapKey is "key: value" of a field of a TAP block, whose lines sit two spaces deeper than their "not ok" line; a deeper
// line is part of a block scalar (an error's lines, a stack).
func tapKey(l string, indent int) (key, val string, ok bool) {
t := strings.TrimLeft(l, " ")
if len(l)-len(t) != indent+2 {
return "", "", false
}
k, v, found := strings.Cut(t, ": ")
if !found {
if k, found = strings.CutSuffix(t, ":"); !found {
return "", "", false
}
}
if k == "" || strings.ContainsAny(k, " ()/") {
return "", "", false
}
return k, strings.TrimSpace(v), true
}
+7 -13
View File
@@ -25,8 +25,6 @@ type ModuleTree struct {
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree // Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise. // is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
Packages []string `json:"packages,omitempty"`
} }
var lockFiles = []string{"go.sum", "package-lock.json"} var lockFiles = []string{"go.sum", "package-lock.json"}
@@ -38,7 +36,7 @@ var lockFiles = []string{"go.sum", "package-lock.json"}
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) { func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json") r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr)) return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
} }
trees := map[string]ModuleTree{} trees := map[string]ModuleTree{}
for _, l := range strings.Split(r.Stdout, "\n") { for _, l := range strings.Split(r.Stdout, "\n") {
@@ -52,7 +50,7 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
} }
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec) t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
if t.Status != 0 || t.Err != "" { if t.Status != 0 || t.Err != "" {
return nil, fmt.Errorf("git rev-parse %s: %s", spec, forgekey.Said(t.Err+"\n"+t.Stderr)) return nil, fmt.Errorf("git rev-parse %s: %s", spec, firstLine(t.Err+" "+t.Stderr))
} }
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)} mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l))) raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
@@ -69,9 +67,6 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
mt.Locks = map[string]string{} mt.Locks = map[string]string{}
} }
mt.Locks[lock] = sha256Hex(raw) mt.Locks[lock] = sha256Hex(raw)
if lock == "go.sum" {
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
}
} }
} }
trees[mdir] = mt trees[mdir] = mt
@@ -80,12 +75,11 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
} }
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499 // narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview decodes the manifest here // clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
// (catalogue.Decode: a build step judges no manifest, novox/hq ADR 0578 §3), and no other build step reads paths from one // the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
// (ADR 0495 clause 1). A manifest that does not decode is not narrowed: the indexer gives it none. A named path absent at // is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
// commit fails, naming the module and the path.
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error { func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
m, err := catalogue.Decode(raw) m, err := catalogue.ParseManifestForInputs(raw)
if err != nil { if err != nil {
return nil return nil
} }
@@ -99,7 +93,7 @@ func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit,
} }
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...) r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
if r.Status != 0 || r.Err != "" { if r.Status != 0 || r.Err != "" {
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr)) return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr))
} }
objects := map[string]string{} objects := map[string]string{}
for _, l := range strings.Split(r.Stdout, "\x00") { for _, l := range strings.Split(r.Stdout, "\x00") {
+8 -5
View File
@@ -75,17 +75,20 @@ func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
} }
} }
// A manifest whose config value falls back to a predefined key is narrowed here: the merge preview decodes it and judges // A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
// nothing (novox/hq ADR 0578 §3), so a build module holding no published list computes the tree the indexer does (ADR // with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
// 0499 clause 2; issue 1751). // tree (ADR 0499 clause 2).
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) { func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
defer catalogue.ReplacePublished(was) defer catalogue.ReplacePublished(was)
root := t.TempDir() root := t.TempDir()
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` + raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}` `"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
if _, err := catalogue.Decode([]byte(raw)); err != nil { if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
t.Fatalf("the preview's read refuses the manifest: %v", err) t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
}
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
t.Fatalf("the indexer's read refuses the manifest: %v", err)
} }
writeTreeFile(t, root, "module.json", raw) writeTreeFile(t, root, "module.json", raw)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran { run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
+3 -5
View File
@@ -33,8 +33,7 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
writeTreeFile(t, root, "module.json", `{"root":1}`) writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum") writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`) writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n" writeTreeFile(t, root, "modules/a/go.sum", "asum")
writeTreeFile(t, root, "modules/a/go.sum", asum)
writeTreeFile(t, root, "modules/a/package-lock.json", "alock") writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`) writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40) merge := strings.Repeat("c", 40)
@@ -56,9 +55,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
t.Fatal(err) t.Fatal(err)
} }
want := map[string]ModuleTree{ want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}}, ".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf(asum), "package-lock.json": hexOf("alock")}, "modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)}, "modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
} }
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
-112
View File
@@ -1,112 +0,0 @@
//go:build linux
package buildstep
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"syscall"
)
const settingsMask = syscall.IN_CLOSE_WRITE | syscall.IN_MOVED_TO | syscall.IN_CREATE
// watchSettings wakes the puller each time its settings file is written or replaced (inotify on the file's directory, so
// a rename over it counts): the event the node-engine's refill of the file is, which a parked puller at capacity 0
// otherwise never sees. No timer: it blocks on the kernel's event. When the kernel drops the watch (IN_IGNORED: the
// directory was replaced or removed) it is added again, said once; while the directory is missing the nearest existing
// ancestor is watched for the directory's return. A watch that cannot start is said once.
func (p *Puller) watchSettings(ctx context.Context) {
if p.SettingsFile == "" {
return
}
fd, err := syscall.InotifyInit1(syscall.IN_CLOEXEC | syscall.IN_NONBLOCK)
if err != nil {
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
return
}
f := os.NewFile(uintptr(fd), "inotify")
defer f.Close()
dir, base := filepath.Split(p.SettingsFile)
dir = filepath.Clean(dir)
// arm watches dir, or when it is missing its nearest existing ancestor; it returns the watch descriptor and whether it
// is the directory's own.
arm := func() (int, bool, error) {
wd, err := syscall.InotifyAddWatch(fd, dir, settingsMask)
if err == nil {
return wd, true, nil
}
for up := filepath.Dir(dir); ; up = filepath.Dir(up) {
wd, aerr := syscall.InotifyAddWatch(fd, up, settingsMask)
if aerr == nil {
// the directory may have come back between the two calls: look once more, now that the ancestor is watched
if own, oerr := syscall.InotifyAddWatch(fd, dir, settingsMask); oerr == nil {
return own, true, nil
}
return wd, false, nil
}
if up == filepath.Dir(up) {
return 0, false, err
}
}
}
wd, own, err := arm()
if err != nil {
p.logf("the settings file %s cannot be watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
return
}
if p.watching != nil {
p.watching()
}
go func() { <-ctx.Done(); f.Close() }()
buf := make([]byte, 4096)
for {
n, err := f.Read(buf)
if err != nil {
return
}
hit, rearm := false, false
for off := 0; off+16 <= n; {
ewd := int(int32(binary.LittleEndian.Uint32(buf[off:])))
mask := binary.LittleEndian.Uint32(buf[off+4:])
l := int(binary.LittleEndian.Uint32(buf[off+12:]))
name := buf[off+16 : off+16+l]
for i, c := range name {
if c == 0 {
name = name[:i]
break
}
}
switch {
case mask&syscall.IN_IGNORED != 0 && ewd == wd:
rearm = true
case ewd == wd && own && string(name) == base:
hit = true
case ewd == wd && !own:
rearm = true // something appeared where the directory was: look again
}
off += 16 + l
}
if rearm {
nwd, nown, err := arm()
if err != nil {
p.logf("the settings file %s is no longer watched, so a refill of it wakes the puller only at another event: %v", p.SettingsFile, err)
return
}
if own || nown { // said once per loss of the watch on the directory itself
if own {
p.logf("the watch of the settings file %s was dropped (its directory was replaced); watching again", p.SettingsFile)
}
}
wd, own = nwd, nown
if p.watching != nil {
p.watching()
}
hit = true // the file may have been refilled meanwhile
}
if hit {
p.Wake()
}
}
}
-8
View File
@@ -1,8 +0,0 @@
//go:build !linux
package buildstep
import "context"
// watchSettings watches nothing off Linux: the puller wakes at its other events.
func (p *Puller) watchSettings(ctx context.Context) {}
-188
View File
@@ -1,188 +0,0 @@
package buscompose
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
)
// **The accounts the grants part wants, and the one shape a held account takes** (novox/hq ADR 0561 §1, §2; issue 774).
//
// The bus composer derives every user from placement, the manifests and the wants. For each account the mesh mints (a
// module's, a module's process's, a machine's provisioner client's), the grants part writes one key of its state
// `accounts`, keyed by the bus user, holding the account's fields that are not secret: what the controller's credential
// carries today beside the password. The vault watches that state, mints the user it holds no value for, and keeps the
// value as the module's own secret on that node, built by HeldCredential, which gives byte for byte the controller's
// shape, so a runtime reads it unchanged. The state is read by the vault alone (NamedReaders), as `bus-accounts` is read
// by the grants part alone.
//
// The node accounts, the controller's own, an enrolment user and a person are never wanted here: the vault mints none
// of them (ADR 0561 §4, §5).
// AccountsState is the grants part's state of wanted accounts, by bus user.
const AccountsState = "accounts"
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
const GrantsAccounts = GrantsModule + "." + AccountsState
// AccountsBucket is the bucket the state lives in.
var AccountsBucket = BucketName(GrantsModule, AccountsState)
// AccountsSubject is the subject of one wanted account's key: `$KV.mesh-grants_accounts.<user>`.
func AccountsSubject(user string) string { return "$KV." + AccountsBucket + "." + user }
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
const AccountIssuedEvent = "account-issued"
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
type ClaimedSeat struct {
Seat string `json:"seat"`
Scope string `json:"scope"`
Serves []string `json:"serves,omitempty"`
}
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
type WantedAccount struct {
User string `json:"user"`
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner.
Kind Kind `json:"kind"`
Node string `json:"node"`
Module string `json:"module"`
// Process is a KindProcess account's process id.
Process string `json:"process,omitempty"`
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
Claims []ClaimedSeat `json:"claims,omitempty"`
// At is when the grants part wrote the key: not compared (Same).
At time.Time `json:"at"`
}
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner}
// principal is the account as the composer names it.
func (w WantedAccount) principal() Principal {
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
}
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
// the one the composer derives from them, and an address to reach the bus at.
func (w WantedAccount) Valid() error {
if !slices.Contains(mintedKinds, w.Kind) {
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
}
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
if !safeSubject.MatchString(v) {
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
}
}
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
}
if w.Kind != KindProcess && w.Process != "" {
return fmt.Errorf("%s: only a process's account names a process", w.User)
}
if want := w.principal().Username(); w.User != want {
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
}
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
}
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
}
return nil
}
// Same says whether two wanted accounts want the same account: every field but when it was written.
func (w WantedAccount) Same(o WantedAccount) bool {
a, b := w, o
a.At, b.At = time.Time{}, time.Time{}
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
// (the controller's names, ADR 0436 §2, ADR 0349 §11).
func (w WantedAccount) OwnSecret() string {
switch w.Kind {
case KindProcess:
return "broker-" + w.Process
case KindProvisioner:
return "provisioner-broker"
}
return "broker"
}
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
var w WantedAccount
if err := json.Unmarshal(raw, &w); err != nil {
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
}
if w.User != user {
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
}
return w, w.Valid()
}
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
type AccountIssued struct {
User string `json:"user"`
Kind Kind `json:"kind"`
Node string `json:"node"`
Module string `json:"module"`
Process string `json:"process,omitempty"`
Generation int `json:"generation"`
At time.Time `json:"at"`
}
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
if err := w.Valid(); err != nil {
return nil, err
}
if password == "" {
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
}
switch w.Kind {
case KindProcess:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
case KindProvisioner:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
}
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
Claims []ClaimedSeat `json:"claims,omitempty"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
Claims: w.Claims})
}
-125
View File
@@ -1,125 +0,0 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// Do the emitters and the consumers of a catalogue agree?
//
// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually
// well-formed and every derivation individually correct, and no cross-module subscription in the
// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to.
// Nothing failed, because a subscription that matches nothing is not an error — it is silence.
//
// The comparison has to be over the whole catalogue, because the two halves live in different
// manifests, and it cannot simply demand that every consumed event have a live emitter: a module
// may be installed long before the one whose events it wants. So the rule is narrower and still
// catches this: **where the emitter is present, it must emit what the consumer asked for.**
// AConsumer is one module's interest in another's events, as this check needs it.
type AConsumer struct {
Module string
Consumes []string
}
// AnEmitter is one module's events.
type AnEmitter struct {
Module string
Emits []string
}
// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them.
//
// Returned as sentences rather than as structs: every one of them is read by a person deciding
// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle.
func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string {
emits := map[string]map[string]bool{}
for _, e := range emitters {
if emits[e.Module] == nil {
emits[e.Module] = map[string]bool{}
}
for _, name := range e.Emits {
emits[e.Module][name] = true
}
}
// A seat's events are published by its holder under the seat's name, so a consumer naming the
// seat is naming something real even though no module declares it as its own.
for _, s := range seats {
if len(s.Emits) == 0 {
continue
}
if emits[s.Name] == nil {
emits[s.Name] = map[string]bool{}
}
for _, name := range s.Emits {
emits[s.Name][name] = true
}
}
var out []string
for _, c := range consumers {
for _, pattern := range c.Consumes {
emitter, event, named := strings.Cut(pattern, ".")
// Every event from everyone, or every event from one module: both are deliberate and
// neither names a particular event to check.
if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest {
continue
}
known, present := emits[emitter]
if !present {
// Not installed here, which is ordinary: a module lives in its own repository and
// may be registered later. Nothing to compare, so nothing to say.
continue
}
if matchesAny(event, known) {
continue
}
out = append(out, fmt.Sprintf(
"%s consumes %q and %s emits %s — so that subscription would match nothing, and "+
"nothing would report it",
c.Module, pattern, emitter, listOf(known)))
}
}
sort.Strings(out)
return out
}
// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern.
func matchesAny(pattern string, emitted map[string]bool) bool {
want := strings.Split(pattern, ".")
for name := range emitted {
if matches(want, strings.Split(name, ".")) {
return true
}
}
return false
}
func matches(pattern, name []string) bool {
for i, part := range pattern {
if part == catalogueTheRest {
return i < len(name)
}
if i >= len(name) {
return false
}
if part != "*" && part != name[i] {
return false
}
}
return len(pattern) == len(name)
}
func listOf(names map[string]bool) string {
if len(names) == 0 {
return "nothing"
}
out := make([]string, 0, len(names))
for n := range names {
out = append(out, n)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
-74
View File
@@ -1,74 +0,0 @@
package buscompose
import (
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the composer is
// given the last word of each (Records.Announced) and grants a reader's `reads: <owner>.<name>` only when the owner
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
// (the transition, until every module announces), and is named as not yet announced.
// ReadsAnnouncedBy splits one reader module's `reads` (`<owner>.<name>`) by what each owner announced: granted when the
// owner announced the state readable (by this reader, where it named its readers: announce.State.Readers), when the
// composer names the reader among the state's readers (NamedReaders, ADR 0547 clause 9), or when the owner announced
// nothing yet (the transition); refused otherwise, and a read named for other modules is refused. There is no judgement
// without the reader (issue 774): one made without it refused every named reader, and Users judges again for the
// reader whatever split it is given (withAnnouncedReads). An empty reader is judged by the readable mark alone.
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
for _, r := range reads {
owner, name, ok := strings.Cut(r, ".")
s, said := announced[owner]
switch {
case reader != "" && !ReaderMay(reader, r):
refused = append(refused, r)
case reader != "" && namedReader(reader, r):
// A state the composer names its readers for is theirs whatever its owner announced.
granted = append(granted, r)
case reader != "" && ok && said && s.Keeps(name):
if s.ReadableBy(name, reader) {
granted = append(granted, r)
} else {
refused = append(refused, r)
}
case !ok || !said || s.Readable(name):
granted = append(granted, r)
default:
refused = append(refused, r)
}
}
return granted, refused
}
// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the
// readers' `reads` as before, until they announce.
func NotAnnounced(r Records) []string {
seen := map[string]bool{}
var out []string
for _, ds := range r.Assigned {
for _, d := range ds {
for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) {
owner, _, ok := strings.Cut(read, ".")
if !ok || seen[owner] {
continue
}
if _, said := r.Announced[owner]; !said {
seen[owner] = true
out = append(out, owner)
}
}
}
}
sort.Strings(out)
return out
}
// namedReader says the composer names module among the readers of read (NamedReaders).
func namedReader(module, read string) bool {
return slices.Contains(NamedReaders[strings.TrimSpace(read)], module)
}
-47
View File
@@ -1,47 +0,0 @@
package buscompose
import "git.novox.be/novox/mesh-sdk/go/delivery"
// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the
// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use.
// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue
// 1908). Nothing is a wildcard over a stream or a worker.
const (
// ViewModule is the module of the delivery view (mesh-delivery-view).
ViewModule = "mesh-delivery-view"
// BuilderModule is the module of the builder (mesh-delivery-builder).
BuilderModule = "mesh-delivery-builder"
)
// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue
// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream.
// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before.
func BuildAskReads() []string {
var out []string
for _, lang := range delivery.AskLanguages {
if stream, err := delivery.AskStream(lang); err == nil {
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
}
}
return out
}
// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its
// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no
// message is pulled, none acknowledged, none published.
func BuildBacklogReads() []string {
var out []string
for _, lang := range delivery.AskLanguages {
stream, err := delivery.AskStream(lang)
if err != nil {
continue
}
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
for _, kind := range delivery.AskKinds {
if w, err := delivery.AskWorker(lang, kind); err == nil {
out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w)
}
}
}
return out
}
-880
View File
@@ -1,880 +0,0 @@
package buscompose
import (
"encoding/json"
"maps"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// Issue 1126 B3a (novox/hq ADR 0590): the composed user lists and the server configuration are golden files this
// composer wrote (testdata/user-lists.json, testdata/composed.conf), first from the lists the ended controller composed
// with its user and the lease's grants taken out. A change to what any user is granted shows here as a diff to read;
// MESH_SDK_UPDATE_GOLDEN=1 writes them anew.
var updateGolden = os.Getenv("MESH_SDK_UPDATE_GOLDEN") == "1"
// golden compares got with the golden file name, or writes it.
func golden(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join("testdata", name)
if updateGolden {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Errorf("%s differs from what is composed now; read the diff and, if it is meant, write it with MESH_SDK_UPDATE_GOLDEN=1", path)
}
}
const SignersBucketName = "mesh-vault_signers"
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
// ADR 0567 §3, §9: a node reads every key of the seat's placement and its candidates (it composes with the whole
// proposal laid over), and none of its open proposals; it writes neither.
reads := strings.Join(NodeComposeReads("laptop"), " ")
for bucket, granted := range map[string]bool{delivery.PlacementBucket: true, delivery.PlacementCandidatesBucket: true,
delivery.PlacementProposalsBucket: false} {
if strings.Contains(reads, "$JS.API.DIRECT.GET.KV_"+bucket+".>") != granted || strings.Contains(reads, "$KV."+bucket+".laptop") {
t.Errorf("a node is granted %s: %v, want %v", bucket, !granted, granted)
}
}
// It hears the creation of those two buckets, and of no other.
if got := NodeComposeAdvisories(); len(got) != 2 || got[0] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_placement" ||
got[1] != "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-assigner_candidates" {
t.Errorf("a node hears the creation of %v", got)
}
type list struct {
Name, List string
Missing []string
Err string
Memberships json.RawMessage
}
var lists []list
for _, f := range UserListFixtures() {
l, missing, err := ComposedFor(f)
row := list{Name: f.Name, List: l, Missing: missing}
if err != nil {
row.Err = err.Error()
}
if row.Memberships, err = json.Marshal(MembershipsFor(f)); err != nil {
t.Fatal(err)
}
// B3a/AC1: no list holds a user for the ended controller.
if strings.Contains(l, `user: "controller"`) {
t.Errorf("%s: the list holds the controller's user", f.Name)
}
lists = append(lists, row)
}
raw, err := json.MarshalIndent(lists, "", " ")
if err != nil {
t.Fatal(err)
}
golden(t, "user-lists.json", append(raw, '\n'))
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true}
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}})
if err != nil {
t.Fatal(err)
}
for subject, want := range map[string]bool{
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true,
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false,
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
"$JS.FC.DEAD_LETTERS.x1": false,
} {
if got := MayPublish(perms, subject); got != want {
t.Errorf("may publish %s: %v, want %v", subject, got, want)
}
}
plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}})
if err != nil {
t.Fatal(err)
}
if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") {
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
}
})
// ADR 0563 §1, §3, §6: each node's engine alone answers its own node's node-engine verbs and says its events, any
// runtime may call them, and it asks the operator under node-engine-<node> alone.
t.Run("a node's engine holds its node seat and asks in its own name", func(t *testing.T) {
router := Seat{Name: AsksSeat, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"messenger_asks"}}
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
"one": {{Module: "messenger", Holds: []Seat{router}, Invokes: []string{"seat:" + EngineSeat + ".root-free"}}},
"two": {{Module: RuntimeModule}, {Module: "shop", Invokes: []string{"*"}}}}})
if err != nil {
t.Fatal(err)
}
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
answers := MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") ||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call.root-free.one.messenger~one")
if engineOne := u.Kind == KindNode && u.Node == "one"; answers != engineOne {
t.Errorf("%s answers one's root-free: %v", u.Username(), answers)
}
switch {
case u.Kind == KindNode && u.Node == "one":
for subject, want := range map[string]bool{
"mesh.seat.operator-channel.accept.ask.node-engine-one": true,
"mesh.seat.operator-channel.accept.cancel.node-engine-one": true,
"mesh.seat.operator-channel.accept.ask.node-engine-two": false,
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-one.hand-over-1": true,
"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.node-engine-two.hand-over-1": false,
"mesh.seat." + EngineSeat + ".event.done.one": true,
"mesh.seat." + EngineSeat + ".event.done.two": false,
"$SRV.INFO": true,
} {
if got := MayPublish(perms, subject); got != want {
t.Errorf("node one may publish %s: %v, want %v", subject, got, want)
}
}
if !MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-one") ||
MaySubscribe(perms, "mesh.seat.operator-channel.event.decided.node-engine-two") ||
!MaySubscribe(perms, "$SRV.INFO."+EngineSeat) || MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool.hand-over.two") {
t.Errorf("node one hears %v", perms.Subscribe)
}
// A verb or an event the seat's contract gains later is its engine's on its own node without a release of
// the grants part (2026-10-31: mesh-host #184's optional verb composition was refused on every node, the
// grants part's mesh-sdk pin predating it): the seat's subjects on its node, any verb, any event.
for _, verb := range []string{"composition", "compose", "a-verb-added-later"} {
if !MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".one") ||
!MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".one.messenger~one") ||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".tool."+verb+".two") ||
MaySubscribe(perms, "mesh.seat."+EngineSeat+".call."+verb+".two.messenger~one") {
t.Errorf("node one does not answer %s on its own node alone: %v", verb, perms.Subscribe)
}
}
if !MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.one") ||
MayPublish(perms, "mesh.seat."+EngineSeat+".event.an-event-added-later.two") {
t.Errorf("node one does not say a later event on its own node alone: %v", perms.Publish)
}
case u.Kind == KindNodeTools || u.Module == "messenger":
if !MayPublish(perms, "mesh.seat."+EngineSeat+".tool.root-free.one") {
t.Errorf("%s cannot call one's root-free", u.Username())
}
}
}
})
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
// other node's.
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true}
perms, err := PermissionsFor(one)
if err != nil {
t.Fatal(err)
}
if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) {
t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)),
MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})))
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) {
t.Error("a node asks own while the vault does not serve it")
}
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
}
// L3 s6 (ADR 0550 §2): while the vault serves `sign`, a node asks it in its own name alone, and reads the vault's
// `signers`, never writing it; the ended controller's lease it reads no more (ADR 0590).
signs, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one", AsksTheVaultToSign: true})
signersGet := "$JS.API.DIRECT.GET.KV_" + SignersBucketName + ".$KV." + SignersBucketName + ".key"
for subject, want := range map[string]bool{VaultSignCall(one): true, signersGet: true,
"$JS.API.DIRECT.GET.KV_mesh-controller_lease.$KV.mesh-controller_lease.holder": false,
VaultSignCall(Principal{Kind: KindNode, Node: "two"}): false, "$KV." + SignersBucketName + ".key": false} {
if MayPublish(signs, subject) != want {
t.Errorf("a node asking sign may publish %s: %v, want %v", subject, !want, want)
}
}
if MayPublish(perms, VaultSignCall(one)) || MayPublish(perms, signersGet) {
t.Error("a node asks sign or reads signers while the vault does not serve sign")
}
// And what a module on another node serves from its settings, that key alone (mailu's smtp domain, issue 1126).
users, err := Users(Records{Nodes: []string{"one", "two"}, Assigned: map[string][]Declared{
"two": {{Module: "mailer", Served: []string{"domain", "not.a.token"}}}}})
if err != nil {
t.Fatal(err)
}
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + ResolvedBucket + ".$KV." + ResolvedBucket + "." + key
}
for _, u := range users {
if u.Kind != KindNode {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
if u.Node == "one" && (!reflect.DeepEqual(u.ServedReads, []string{"two.mailer.domain"}) || !MayPublish(perms, get("two.mailer.domain")) ||
MayPublish(perms, get("two.mailer.sitename"))) {
t.Errorf("node one reads %v of two's resolved values", u.ServedReads)
}
if u.Node == "two" && len(u.ServedReads) != 0 {
t.Errorf("node two is granted its own served keys apart: %v", u.ServedReads)
}
}
})
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
// is the one above (AC2: the controller's golden lists, unchanged).
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
users, err := Users(Records{Nodes: []string{"ace", "g14"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
NodeLogins: map[string]delivery.NodeLogin{
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
// A node that rotated before and rotates no more logs in as its second login alone.
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode {
p, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
perms[u.Username()] = p
}
}
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
}
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
own := func(p Permissions, login string) Permissions {
swap := func(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
}
return out
}
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
AllowResponses: p.AllowResponses}
}
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
}
for login, p := range perms {
node, _ := delivery.LoginNode(login)
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
t.Errorf("%s's inbox: %v", login, p.Subscribe)
}
}
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
}
confirm := func(login string) string {
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
}
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
confirm("node.g14--b"): true, confirm("node.g14"): false} {
if MayPublish(perms["node.g14--b"], subject) != want {
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
}
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
t.Error("a node reads node-logins while the vault does not rotate")
}
// And nothing but the grants part names node-logins among its reads.
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
t.Error("node-logins' named readers")
}
})
// 774/S40/AC1 (ADR 0587 §4, §6, ADR 0590): an enrolling node's user may publish the redeem call in its own name and
// nothing else, and subscribe its own inbox alone.
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
{
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
if err != nil {
t.Fatal(err)
}
var lab *Principal
for i := range users {
if users[i].Username() == "enrol.lab" {
lab = &users[i]
}
}
if lab == nil {
t.Fatal("no user enrol.lab")
}
perms, err := PermissionsFor(*lab)
if err != nil {
t.Fatal(err)
}
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
perms.AllowResponses {
t.Errorf("enrol.lab publishes %v, subscribes %v, answers %v", perms.Publish, perms.Subscribe, perms.AllowResponses)
}
}
// No holder serving redeem: the user may enrol nowhere, refused by name.
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab"}); err == nil {
t.Error("an enrolment user that may enrol nowhere was composed")
}
})
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
// however wide its grant; and its bucket is read by the grants part alone besides it.
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
{Module: "mesh-vault", Holds: []Seat{vault}},
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
{Module: "shop", Invokes: calls[:2]},
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
if err != nil {
t.Fatal(err)
}
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
name := u.Username()
for _, verb := range []string{"enrolment-token", "node-account"} {
enrols := name == "ace.mesh-enrolment"
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
t.Errorf("%s may call %s: %v", name, verb, !enrols)
}
}
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
}
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
t.Errorf("%s writes the enrolments", name)
}
}
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
t.Error("the enrolments' named readers")
}
})
t.Run("the golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
[]Principal{
{Kind: KindEnrolment, Node: "one", Redeems: true, PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
{Kind: KindModule, Node: "two", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
})
if err != nil {
t.Fatal(err)
}
golden(t, "composed.conf", []byte(got))
})
// 774/AC2 (novox/hq ADR 0561 §2): a wanted account round-trips through the grants part's state `accounts`, keyed by
// its user, at the subject `$KV.mesh-grants_accounts.<user>`, read by the vault alone; and the vault's account-issued
// names the account, never a value.
t.Run("a wanted account is kept by its user and read by the vault alone", func(t *testing.T) {
for _, w := range []WantedAccount{
{User: "ace.mesh-build-go", Kind: KindModule, Node: "ace", Module: "mesh-build-go", URL: "nats://novox.mesh:4222",
Fingerprint: "ab", Claims: []ClaimedSeat{{Seat: "node-build-go", Scope: "node", Serves: []string{"build"}}}},
{User: "novox.mesh-mail.send", Kind: KindProcess, Node: "novox", Module: "mesh-mail", Process: "send",
URL: "nats://novox.mesh:4222"},
{User: "provisioner.g14", Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client",
URL: "nats://novox.mesh:4222", Fingerprint: "ab"},
} {
if err := w.Valid(); err != nil {
t.Fatalf("%s: %v", w.User, err)
}
raw, err := json.Marshal(w)
if err != nil {
t.Fatal(err)
}
back, err := ReadWantedAccount(w.User, raw)
if err != nil || !back.Same(w) {
t.Errorf("%s does not round-trip: %+v, %v", w.User, back, err)
}
if got := AccountsSubject(w.User); got != "$KV.mesh-grants_accounts."+w.User {
t.Errorf("%s is kept at %s", w.User, got)
}
if _, err := ReadWantedAccount("someone.else", raw); err == nil {
t.Errorf("%s was read under another user's key", w.User)
}
}
for _, bad := range []WantedAccount{
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "y", URL: "nats://a:1"},
{User: "ace.x.p", Kind: KindProcess, Node: "ace", Module: "x", URL: "nats://a:1"},
{User: "node.ace", Kind: KindNode, Node: "ace", URL: "nats://a:1"},
{User: "controller", Kind: "controller", URL: "nats://a:1"},
{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x"},
} {
if bad.Valid() == nil {
t.Errorf("%+v is taken as a wanted account", bad)
}
}
if ReaderMay("mesh-delivery-assigner", GrantsAccounts) || !ReaderMay(VaultSeat, GrantsAccounts) {
t.Errorf("%s is read by %v alone, want the vault's", GrantsAccounts, NamedReaders[GrantsAccounts])
}
e := AccountIssued{User: "ace.x", Kind: KindModule, Node: "ace", Module: "x", Generation: 1}
raw, _ := json.Marshal(e)
if strings.Contains(string(raw), "password") || strings.Contains(string(raw), "hash") ||
AccountIssuedSubject != "mesh.mod.mesh-vault.event.account-issued" {
t.Errorf("account-issued says %s at %s", raw, AccountIssuedSubject)
}
})
// 774/AC3 (novox/hq ADR 0561 §2): the account the vault seals is, byte for byte, what the controller hands for each of
// its three kinds: testdata/controller-credentials.json was written by the controller's own code (its "how").
t.Run("a held account is the controller's shape byte for byte", func(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "controller-credentials.json"))
if err != nil {
t.Fatal(err)
}
var captured struct {
Rows []struct{ Name, Held string }
}
if err := json.Unmarshal(raw, &captured); err != nil {
t.Fatal(err)
}
vault := ClaimedSeat{Seat: "mesh-vault", Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued",
"rotate", "move-in", "node-key", "own", "move-asks"}}
controller := ClaimedSeat{Seat: "mesh-controller", Scope: "mesh", Serves: strings.Split("tools,calls,status,nodes,"+
"node,placement-export,modules,seats,builds,plans,delivery-plan,delivery-order,delivery-check,deliver,"+
"delivery-stop,delivery-backups-again,delivery-walks,plan,assign,unassign,pin,unpin,push,rotate,give,issued,"+
"secret-move,secret-ask,issue,token,settings,command,queue,cancel,clear,replay,try,kill,pause,resume,hand-act,"+
"drill,warranted,hand-acts,durations,conditions,healers,doctor,upgrade,bus,retire,cleanup,judge-directories,"+
"root-free,dead-letters,data,build,artifacts,held,collect,move-placement,move-config,mirrors,images", ",")}
at := "nats://10.0.0.1:4222"
wanted := map[string]struct {
w WantedAccount
password string
}{
"module": {WantedAccount{User: "laptop.cap-bot", Kind: KindModule, Node: "laptop", Module: "cap-bot", URL: at,
Fingerprint: "ab:cd", Claims: []ClaimedSeat{vault, {Seat: "node-service-manager", Scope: "node",
Serves: []string{"status"}}, controller, {Seat: "no-such-seat", Scope: "node"}}}, "pw-module"},
"module-no-claims-no-fingerprint": {WantedAccount{User: "laptop.cap-plain", Kind: KindModule, Node: "laptop",
Module: "cap-plain", URL: at}, "pw-plain"},
"provisioner": {WantedAccount{User: "provisioner.laptop", Kind: KindProvisioner, Node: "laptop", Module: "nats",
URL: at, Fingerprint: "ab:cd"}, "pw-prov"},
"process": {WantedAccount{User: "laptop.mesh-mail.send", Kind: KindProcess, Node: "laptop", Module: "mesh-mail",
Process: "send", URL: at, Fingerprint: "ab:cd"}, "pw-proc"},
}
if len(captured.Rows) != len(wanted) {
t.Fatalf("%d rows captured, %d wanted", len(captured.Rows), len(wanted))
}
for _, row := range captured.Rows {
c, ok := wanted[row.Name]
if !ok {
t.Fatalf("no wanted account for the captured %s", row.Name)
}
got, err := HeldCredential(c.w, c.password)
if err != nil || string(got) != row.Held {
t.Errorf("%s:\n got %s (%v)\nwant %s", row.Name, got, err, row.Held)
}
}
if _, err := HeldCredential(wanted["module"].w, ""); err == nil {
t.Error("an account was held with no password")
}
})
// 774 subtask 8 (novox/hq ADR 0561 §6): a module's consumer is created as the grants part creates it, and every read is
// judged for its reader.
t.Run("every read is judged for its reader; a consumer is created as the grants part creates it", func(t *testing.T) {
announced := map[string]announce.States{}
// Issue 774 (sweep item 2): Users judges every read for its reader again, whatever split the caller made: a
// reader-less split refused the vault the grants part's accounts it is named for.
announced["mesh-grants"] = announce.States{Module: "mesh-grants", States: []announce.State{{Name: "accounts", Readers: []string{VaultSeat}}}}
announced["disk-load"] = announce.States{Module: "disk-load", States: []announce.State{{Name: "readings"}}}
judged, err := Users(Records{Nodes: []string{"a"}, Announced: announced, Assigned: map[string][]Declared{"a": {
{Module: VaultSeat, ReadsRefused: []string{GrantsAccounts}},
{Module: "restic", ReadsRefused: []string{"disk-load.readings"}},
{Module: "messenger", Reads: []string{GrantsAccounts}}}}})
if err != nil {
t.Fatal(err)
}
reads := map[string]string{}
for _, u := range judged {
if u.Kind == KindModule {
reads[u.Module] = strings.Join(u.Reads, ",")
}
}
if reads[VaultSeat] != GrantsAccounts || reads["restic"] != "" || reads["messenger"] != "" {
t.Errorf("the reads granted for their readers: %v", reads)
}
// AccountsEntry is the very lines ComposeAccounts writes for a user, so a writer of kept users repeats none.
filled, _ := WithPasswords(judged, map[string]string{judged[0].Username(): "$2a$11$h"})
list, err := ComposeAccounts(filled[:1])
perms, perr := PermissionsFor(filled[0])
if err != nil || perr != nil || !strings.Contains(list, " users = [\n"+AccountsEntry(filled[0].Username(), "$2a$11$h", perms)+" ]\n") {
t.Errorf("the accounts entry is not ComposeAccounts' own: %v %v\n%s", err, perr, list)
}
c, _ := ConsumerFor(Principal{Kind: KindModule, Node: "a", Module: "m", Consumes: []string{"x.happened"}})
cfg := ConsumerConfig(c)
if cfg.Durable != "a_m" || cfg.AckPolicy != jetstream.AckExplicitPolicy || cfg.AckWait != 30*time.Second ||
cfg.MaxDeliver != 5 || cfg.DeliverPolicy != jetstream.DeliverNewPolicy || len(cfg.FilterSubjects) != 2 ||
cfg.Description == "" {
t.Errorf("a module's consumer is created as %+v", cfg)
}
// The grants part is granted the create of each account's consumer by its stream and name, and no other.
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
{Module: GrantsModule}, {Module: "m", Consumes: []string{"x.happened"}}, {Module: "quiet"}}}})
if err != nil {
t.Fatal(err)
}
for _, u := range users {
if u.Module != GrantsModule || u.Kind != KindModule {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
if !MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_quiet") ||
MayPublish(perms, "$JS.API.CONSUMER.DELETE.EVENTS.a_m") || MayPublish(perms, "$JS.API.CONSUMER.CREATE.EVENTS.a_m.mesh.mod.x.event.happened") {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
healthChecks(t)
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
QueueOwner{Process: "p", Stream: "M_WORK", MaxDeliver: 3}))
if q.Durable != "a_m_p" || q.FilterSubject != "" || q.MaxDeliver != 3 || q.DeliverPolicy != jetstream.DeliverAllPolicy {
t.Errorf("a process's queue consumer is created as %+v", q)
}
})
}
// healthChecks are the health holders' credential cases (ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8; issue 1952 S5).
func healthChecks(t *testing.T) {
t.Helper()
announced := map[string]announce.States{
"mesh-healers": {Module: "mesh-healers", States: []announce.State{{Name: "attempts", Readable: true}, {Name: "would", Readable: true}}}}
for _, c := range []struct {
reader, read string
may bool
}{
{"mesh-conditions", "mesh-healers.attempts", true}, {"mesh-conditions", HealersWould, false}, {"mesh-delivery-view", HealersWould, false},
// ADR 0577 clause 1: the node-engine's data items are disk-load's alone to read.
{"disk-load", delivery.EngineDataRead, true}, {"restic", delivery.EngineDataRead, false},
} {
granted, _ := ReadsAnnouncedBy(c.reader, []string{c.read}, announced)
if (len(granted) == 1) != c.may {
t.Errorf("%s reads %s: %v, want %v", c.reader, c.read, granted, c.may)
}
}
bucket := func(m, n string) Bucket { return Bucket{Module: m, Name: n} }
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {
{Module: "mesh-conditions", State: []Bucket{bucket("mesh-conditions", "conditions"), bucket("mesh-conditions", "takeover")},
Reads: []string{"mesh-self-check.findings", "mesh-healers.attempts"}},
{Module: "mesh-self-check", State: []Bucket{bucket("mesh-self-check", "findings")}, Reads: []string{"restic.good"}},
// Issue 1955 L7: the grants part keeps the hearings it withholds; the self-check reads them.
{Module: GrantsModule, State: []Bucket{bucket(GrantsModule, GrantsWithheldState)}},
{Module: "mesh-healers", State: []Bucket{bucket("mesh-healers", "findings"), bucket("mesh-healers", "attempts"), bucket("mesh-healers", "would")}},
{Module: WatchmanModule, State: []Bucket{bucket(WatchmanModule, "findings")}, Reads: []string{delivery.NodeHealthRead}},
{Module: "mesh-hand-acts", State: []Bucket{bucket("mesh-hand-acts", "warrants")}, Logs: []Log{{Module: "mesh-hand-acts", Name: "acts"}}},
// ADR 0570 clauses 1, 8 (issue 1954 L6-P5): mesh-provisioner keeps provisions and plans; the vault and a provider
// read them.
{Module: "mesh-provisioner", State: []Bucket{bucket("mesh-provisioner", "provisions"), bucket("mesh-provisioner", "plans"),
bucket("mesh-provisioner", "registry")}},
{Module: "mesh-vault", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
{Module: "postgres", Reads: []string{"mesh-provisioner.provisions", "mesh-provisioner.plans"}},
// The node's provisioner client reads its machine's plans and records no needs (ADR 0570 clause 2; its V3).
{Module: "mesh-provisioner-client", ProvisionsNode: true},
// ADR 0571 clauses 2, 4 (issue 1955): the seat mesh-artifact-sweep says artifacts-gone; the assigner keeps seat-holders.
{Module: "artifact-store-tools", Holds: []Seat{{Name: "mesh-artifact-sweep", Emits: []string{"artifacts-gone"}}},
Reads: []string{delivery.SeatHoldersRead}},
{Module: delivery.AssignerModule, State: []Bucket{bucket(delivery.AssignerModule, delivery.SeatHoldersState)}},
// ADR 0571 clause 3: disk-load keeps the readings; the backup holder reads them.
// ADR 0577 clause 1: and it reads its own node's keys of the node-engine's data items, which restic may not.
{Module: "disk-load", State: []Bucket{bucket("disk-load", "readings")}, Reads: []string{delivery.EngineDataRead}},
{Module: "restic", Reads: []string{"disk-load.readings", delivery.EngineDataRead}, State: []Bucket{bucket("restic", "good")}},
// ADR 0571 clauses 1, 9: the indexer reads the builder's log through its own consumer; the catalogue module asks
// the builder's tool.
{Module: delivery.BuilderModule, Logs: []Log{{Module: delivery.BuilderModule, Name: delivery.BuilderLog, MaxAgeDays: 30}}},
{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead}},
{Module: "mesh-catalog", Invokes: []string{delivery.BuilderModule + "." + delivery.BuildsSinceTool}},
{Module: "telegram", Holds: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}},
Processes: []ProcessPrincipal{{Process: "w", Queues: []QueueOwner{{Process: "w", Stream: "M_WORK"}, {Process: "w", Stream: "KV_x"}}}}},
{Module: "nats", SnapshotsTheBus: true, Processes: []ProcessPrincipal{{Process: "nats-tools",
Removes: []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}}, {Process: "other"}}},
{Module: "shop", Processes: []ProcessPrincipal{{Process: "nats-tools", Removes: []string{catalogue.RemovesEngineActs}}}},
}}})
if err != nil {
t.Fatal(err)
}
may := map[string][]string{
"a.mesh-conditions": {"$KV.mesh-conditions_conditions.machine.g14.silent"},
"a.mesh-provisioner": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-vault": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.postgres-database",
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak"},
"a.mesh-hand-acts": {"$KV.mesh-hand-acts_warrants.mesh-config.ask-1", "mesh.log.mesh-hand-acts.acts.act-1"},
"a.disk-load": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg",
"$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.a.*"},
"provisioner.a": {"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.a.keycloak",
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.a.keycloak",
// Every machine's plans, as the client V3 watches them (issue 774, 2026-10-30).
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_plans.c1.$KV.mesh-provisioner_plans.>",
"$JS.API.DIRECT.GET.KV_mesh-provisioner_plans.$KV.mesh-provisioner_plans.b.keycloak",
// The needs, for the client before V3, until the lease write (review round 3).
"$KV." + ProvisionerNeedsBucket + ".a.keycloak.db.x"},
// ADR 0571 clause 3: restic's bundle writes each item's last good night as its own state; the self-check reads it.
"a.mesh-self-check": {"$JS.API.DIRECT.GET.KV_restic_good.$KV.restic_good.a.pg.data"},
"a.mesh-grants": {"$KV.mesh-grants_withheld.reader.mail.a"},
"a.restic": {"$JS.API.DIRECT.GET.KV_disk-load_readings.$KV.disk-load_readings.a.pg.data", "$KV.restic_good.a.pg.data",
// ADR 0571 clause 7: its own membership from the grants part's state.
"$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic"},
"a.mesh-delivery-indexer": {"$JS.API.CONSUMER.CREATE.LOG_mesh-delivery-builder_build-log.r1.mesh.log.mesh-delivery-builder.build-log.letta",
"$JS.API.CONSUMER.MSG.NEXT.LOG_mesh-delivery-builder_build-log.r1", "$JS.API.CONSUMER.DELETE.LOG_mesh-delivery-builder_build-log.r1",
"$JS.API.DIRECT.GET.LOG_mesh-delivery-builder_build-log.mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-delivery-builder": {"mesh.log.mesh-delivery-builder.build-log.letta"},
"a.mesh-catalog": {"mesh.mod.mesh-delivery-builder.tool.builder_builds_since"},
WatchmanModule: {"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES." + WatchmanModule, "$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTER_NOTICES." + WatchmanModule,
"$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller", "$JS.API.STREAM.MSG.GET.DEAD_LETTERS"},
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.EVENTS", "$JS.API.STREAM.INFO.SEAT_TELEGRAM_SENDER",
"$JS.API.STREAM.MSG.GET.M_WORK", "$JS.API.STREAM.INFO.EVENTS", "mesh.events.dead.EVENTS.a_m"},
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
// Issue 1126 B5 (ADR 0590 clause 6): every leftover of the ended controller, each by its name, read and deleted.
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
"$JS.API.STREAM.DELETE.KV_mesh-controller_rollouts", "$JS.API.STREAM.DELETE.KV_mesh-controller_states-announced",
"$JS.API.STREAM.DELETE.CONTROL", "$JS.API.STREAM.DELETE.OBJ_declaration-files-a",
"$JS.API.CONSUMER.DELETE.EVENTS.controller", "$JS.API.CONSUMER.DELETE.EVENTS.controller-merges",
"$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-delivery", "$JS.API.CONSUMER.DELETE.CONTROL.controller",
// And the consumers no part composes that the controller made before it ended: mesh-catalog's of the
// controller's built-before, and each node's engine's of restic's backed-up (issue 1126).
"$JS.API.CONSUMER.INFO.EVENTS.novox_mesh-catalog", "$JS.API.CONSUMER.DELETE.EVENTS.novox_mesh-catalog",
"$JS.API.CONSUMER.INFO.EVENTS.a_mesh-host", "$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-host",
"$JS.API.CONSUMER.INFO.CONTROL.controller"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
"a.mesh-conditions": {"$KV.mesh-self-check_findings.x", "$KV.mesh-healers_attempts.x", "$KV.mesh-controller_conditions.x"},
"a.mesh-self-check": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch", "$KV.restic_good.a.pg.data",
"$KV.mesh-grants_withheld.reader.mail.a", "$SRV.INFO", "$SRV.PING.node-tools",
"$JS.API.DIRECT.GET.KV_mesh-grants_withheld.$KV.mesh-grants_withheld.reader.mail.a"},
// Only mesh-provisioner writes provisions and plans, and it writes none of the grants part's states (ADR 0570
// clauses 1, 8).
"a.mesh-provisioner": {"$KV.mesh-grants_accounts.a.m", "$KV.mesh-grants_user-list.sealed"},
"a.mesh-vault": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak",
"mesh.seat.mesh-artifact-sweep.event.artifacts-gone"},
// Each writes its own state alone, and these users are composed.
"a.disk-load": {"$KV.restic_good.a.pg.data", "$KV.mesh-host_data.a.pg",
"$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.b.pg", "$JS.API.CONSUMER.CREATE.KV_mesh-host_data.c1.$KV.mesh-host_data.b.*"},
"a.mesh-delivery-builder": {"$KV.disk-load_readings.a.pg.data"},
// It reads every machine's plans (a provider's consumers are bound there, ADR 0373) but writes none, nor another
// machine's needs, which it keeps for the client before V3 until the lease write.
"provisioner.a": {"$KV.mesh-provisioner_plans.a.keycloak", "$KV." + ProvisionerNeedsBucket + ".b.keycloak.db.x"},
// Only the assigner writes seat-holders.
"a.artifact-store-tools": {"$KV.mesh-delivery-assigner_seat-holders.mesh-broker"},
// Only disk-load writes its readings.
"a.restic": {"$KV.disk-load_readings.a.pg.data", "$JS.API.DIRECT.GET.KV_mesh-host_data.$KV.mesh-host_data.a.pg"},
"a.postgres": {"$KV.mesh-provisioner_provisions.a.keycloak.postgres-database", "$KV.mesh-provisioner_plans.a.keycloak"},
// Nor mesh-hand-acts' log and warrants, which it alone writes (ADR 0565 clause 10).
"a.mesh-healers": {"$KV.mesh-conditions_conditions.x", "$KV.mesh-conditions_takeover.switch",
"$KV.mesh-hand-acts_warrants.x", "mesh.log.mesh-hand-acts.acts.x"},
// The watchman never deletes or changes the controller's consumer (clause 5).
// Its reads and deletes of a message are on the streams a dead letter comes from, named, never a bucket's
// (the lead, 2026-10-29).
WatchmanModule + " sources": {"$JS.API.STREAM.MSG.GET.KV_mesh-vault_bus-accounts", "$JS.API.STREAM.MSG.GET.KV_x",
"$JS.API.STREAM.MSG.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", "$JS.API.STREAM.MSG.GET.OTHER",
// In L4 it neither delivers again nor drops (clause 5, the lead 2026-10-29).
"$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.MSG.DELETE.DEAD_LETTERS", "$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x",
"mesh.again.a_m.x"},
WatchmanModule: {"$KV.mesh-conditions_conditions.x", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller",
"$JS.API.CONSUMER.CREATE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES." + WatchmanModule},
// The bus module's tools remove the one consumer and the acts' keys, nothing else (Limits 4); its other accounts
// remove nothing; an account is granted the removals its bus declares and no other (the catalogue refuses them
// outside the bus module).
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
// It updates nothing, purges nothing, deletes no stream but the leftovers' and writes no key of them (ADR 0590).
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease", "$JS.API.STREAM.PURGE.KV_mesh-controller_lease",
"$JS.API.STREAM.DELETE.KV_mesh-controller_other", "$JS.API.STREAM.DELETE.OBJ_declaration-files-zz",
"$JS.API.CONSUMER.DELETE.EVENTS.a_mesh-delivery", "$KV.mesh-controller_lease.holder"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
}
// ADR 0571 clause 1: a read naming another module's log is issued to the reader as a log it reads, never writes.
if m := MembershipFor("a", Declared{Module: delivery.IndexerModule, Reads: []string{delivery.BuildLogRead, "x.y"}},
Placements{Logs: map[string]bool{delivery.BuildLogRead: true}}); len(m.Logs) != 1 || m.Logs[0].Writes ||
m.Logs[0].Stream != "LOG_mesh-delivery-builder_build-log" || m.Logs[0].Name != delivery.BuildLogRead {
t.Errorf("the indexer's membership issues the logs %+v", m.Logs)
}
// ADR 0565 clauses 6, 7 (issue 1953 L5-S4): a node's engine writes its own packages key and no other node's; only the
// assigner writes its policies.
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
continue
}
// ADR 0570 clause 6: a node's engine reads its own node's provision keys, and no other node's, and writes none.
if u.Kind == KindNode && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.a.keycloak.db") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-provisioner_provisions.$KV.mesh-provisioner_provisions.b.keycloak.db") ||
MayPublish(perms, "$KV.mesh-provisioner_provisions.a.keycloak.db")) {
t.Errorf("node %s reads the provisions %v", u.Node, perms.Publish)
}
// Issue 774: a node's engine writes its own health report's key and no other node's.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_health.a") || MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("node %s writes the health reports %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && u.Module == WatchmanModule && (!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b") ||
MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
}
// ADR 0565 clause 6, ADR 0590: and the ended controller's mirror it reads no more.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a")) {
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
t.Errorf("%s writes the assigner's policies", u.Username())
}
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
// the grants part's state.
if MaySubscribe(perms, "mesh.assignment.a.restic") {
t.Errorf("%s hears mesh.assignment", u.Username())
}
}
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
}
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
// long as the log (ADR 0297).
if aged, kept := (Log{Module: "mesh-hand-acts", Name: "acts", MaxAgeDays: 400}).Config(), (Log{Module: "m", Name: "l"}).Config(); aged.MaxAge != 400*24*time.Hour || kept.MaxAge != 0 || !aged.DenyDelete {
t.Errorf("an aged log's stream keeps %s, an unaged one %s", aged.MaxAge, kept.MaxAge)
}
// The tools account's secret is the module's own broker-<binary>, which its manifest's own-secrets names and the
// vault keeps by that name (AccountSecret).
if w := (WantedAccount{User: "a.nats.nats-tools", Kind: KindProcess, Node: "a", Module: "nats", Process: "nats-tools",
URL: "nats://a:4222"}); w.Valid() != nil || w.OwnSecret() != "broker-nats-tools" {
t.Errorf("the tools account is held in %q (%v)", w.OwnSecret(), w.Valid())
}
// Issue 1952: a module that holds a seat says its own seat's events, also when the tool runner carries it, so the
// self-check's `checked`, the keeper's condition events and the healers' `healer-acted` reach their hearers. Its
// membership lists them, the runtime is granted them; a user of the seat is not, nor a machine's event on a node seat
// (granted per machine, to the holder's own account only).
selfCheck := Declared{Module: "mesh-self-check", Holds: []Seat{{Name: "mesh-self-check", Scope: "mesh", Emits: []string{"checked"}}}}
for _, c := range []struct {
name string
d Declared
subject string
may bool
}{
{"the self-check says checked", selfCheck, "mesh.seat.mesh-self-check.event.checked", true},
{"the keeper says a condition raised", Declared{Module: "mesh-conditions", Holds: []Seat{{Name: "mesh-conditions", Scope: "mesh",
Emits: []string{"condition-raised", "condition-cleared"}}}}, "mesh.seat.mesh-conditions.event.condition-cleared", true},
{"a user does not say the seat's events", Declared{Module: "mesh-delivery-view", Uses: []Seat{{Name: "mesh-self-check",
Scope: "mesh", Emits: []string{"checked"}}}}, "mesh.seat.mesh-self-check.event.checked", false},
{"the holder says no other seat's", selfCheck, "mesh.seat.mesh-conditions.event.condition-raised", false},
{"a machine's event on a node seat is not composed here", Declared{Module: "pause", Holds: []Seat{{Name: "node-power",
Scope: "node", Emits: []string{"paused.*"}}}}, "mesh.seat.node-power.event.paused.b", false},
} {
m := MembershipFor("a", c.d, Placements{})
listed := m.SeatTraffic != nil && slices.ContainsFunc(m.SeatTraffic.Publish, func(p string) bool { return p == c.subject })
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Carries: []Declared{c.d}})
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if listed != c.may || MayPublish(perms, c.subject) != c.may {
t.Errorf("%s: the membership lists %s %v and the runtime may publish it %v, want %v", c.name, c.subject, listed,
MayPublish(perms, c.subject), c.may)
}
}
seen := map[string]bool{}
for _, u := range users {
name := u.Username()
if u.Module == WatchmanModule && u.Kind == KindModule {
name = WatchmanModule
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if name == WatchmanModule {
// It hears its watchdogs' words on core subjects (Limits 3, 11).
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
}
// Never the bus's API audit, which carries every API answer in the account (the lead, 2026-10-29).
if MaySubscribe(perms, "$JS.EVENT.ADVISORY.API") || MaySubscribe(perms, "$JS.EVENT.ADVISORY.API.x") {
t.Error("the watchman hears the bus's API audit")
}
if !MayPublish(perms, "$JS.API.DIRECT.GET.PROV_APPLIED.prov.applied.g14.keycloak") {
t.Error("the watchman may not read the newest provisioner report")
}
}
subjects := append(append([]string(nil), may[name]...), may[name+" sources"]...)
denied := append(append([]string(nil), mayNot[name]...), mayNot[name+" sources"]...)
if len(may[name+" sources"]) > 0 {
seen[name+" sources"] = true
}
for _, subject := range subjects {
seen[name] = true
if !MayPublish(perms, subject) {
t.Errorf("%s may not publish %s", name, subject)
}
}
for _, subject := range denied {
seen[name] = true
if MayPublish(perms, subject) {
t.Errorf("%s may publish %s", name, subject)
}
}
}
// Every row names a user that was composed, in either table: a row for a user absent would pass for nothing.
for _, table := range []map[string][]string{may, mayNot} {
for name := range table {
if !seen[name] {
t.Errorf("no user %s was composed", name)
}
}
}
}
-117
View File
@@ -1,117 +0,0 @@
package buscompose
import (
"regexp"
"strings"
)
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
//
// mesh.mod.<module>.call.<tool>[.<node>].<caller>
// mesh.seat.<seat>.call.<verb>[.<node>].<caller>
//
// The last token is the bus user that published the call, and each user is granted these subjects with its own
// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the
// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape).
//
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
// wildcard over the `tool` kind — `.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>` for every
// tool — and either would match a `tool` subject with any caller appended, so a caller could name another.
// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is
// never persisted (design 25 §3).
//
// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a
// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left
// unable to call in its own name, and a new grant to call is one in both shapes by construction.
//
// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap:
// their retirement is hq issue 464.
const CallKind = "call"
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part
// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not
// a user's.
func CallerToken(user string) string {
if !userName.MatchString(user) {
return ""
}
return strings.ReplaceAll(user, ".", "~")
}
// toolGrant splits a grant on the `tool` kind — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>`, any
// part possibly a wildcard — at the kind; ok is false for any other subject.
func toolGrant(subject string) (head, rest string, ok bool) {
parts := strings.SplitN(subject, ".", 5)
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
parts[2] == "" || parts[4] == "" {
return "", "", false
}
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
}
// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's
// or the user is not a bus user.
func CalledSubject(subject, user string) string {
head, rest, ok := toolGrant(subject)
token := CallerToken(user)
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + "." + token
}
// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their
// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's.
func CalledPattern(subject string) string {
head, rest, ok := toolGrant(subject)
if !ok || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + ".*"
}
// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last
// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or
// the tool and the machine — so it becomes both, each ending in the caller.
func calledPublish(grant, token string) []string {
head, rest, ok := toolGrant(grant)
if !ok || token == "" {
return nil
}
base := head + "." + CallKind + "."
switch {
case rest == ">":
return []string{base + "*." + token, base + "*.*." + token}
case strings.HasSuffix(rest, ".>"):
return []string{base + strings.TrimSuffix(rest, ">") + "*." + token}
}
return []string{base + rest + "." + token}
}
// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller.
func calledSubscribe(grant string) []string {
head, rest, ok := toolGrant(grant)
if !ok {
return nil
}
base := head + "." + CallKind + "."
if strings.HasSuffix(rest, ">") {
return []string{base + rest}
}
return []string{base + rest + ".*"}
}
// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in
// its own name, to subscribe naming anybody.
func callerNamed(user string, pub, sub []string) ([]string, []string) {
token := CallerToken(user)
for _, g := range pub {
pub = append(pub, calledPublish(g, token)...)
}
for _, g := range sub {
sub = append(sub, calledSubscribe(g)...)
}
return unique(pub), unique(sub)
}
-11
View File
@@ -1,11 +0,0 @@
package buscompose
import "strings"
// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the
// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares.
// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state.
func IsCancelledSet(bucket string) bool {
return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled")
}
-71
View File
@@ -1,71 +0,0 @@
package buscompose
import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding the
// compositions the ended controller made for that node, which only that node's node-engine reads. Nothing writes them
// since the controller ended (ADR 0590); a node composes its own modules (ADR 0547 §2).
// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and
// 9): every key of its own composition bucket, and its own wants (`want.<node>.<module>` and `want.<node>.node`) in the
// assigner's bucket; nothing of another node's, nor the bucket of digests.
func ComposedReads(node string) ([]string, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return nil, err
}
if !safeSubject.MatchString(node) {
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
}
return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil
}
// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want (ADR
// 0539 clause 16, ADR 0429 §14): its backup holder backs a data module up before it moves, and says when it was. Asked
// of its own machine only, `mesh.seat.<seat>.tool.<verb>.<node>`.
var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}}
// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node.
func NodeAgentAsks(node string) ([]string, error) {
if !safeSubject.MatchString(node) {
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
}
var out []string
for _, v := range VerbsTheNodeAgentAsks {
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node)
}
return out, nil
}
// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a
// backup is taken (`backed-up`), from whichever module holds node-backup on its machine (restic today).
var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"}
// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears.
// A second holder of the seat is a change of this grant.
const BackupHolderModule = "restic"
// DeclarationsStream is the stream of each node's last declaration, `mesh.node.<node>.declare` (issue 107). Release 6
// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the
// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have
// names the new node-engine, and from then on neither asserts it nor any node's consumer on it; nor makes it again once
// it is gone from the bus for any reason, since a stream made anew would hold no node's last declaration (issue 1945).
const DeclarationsStream = "NODES"
// RaiseAfterDeclarations is Raise once the declarations' stream is retired: the mesh's streams and consumers but the
// declarations' stream, and no node's consumer of a declaration.
func RaiseAfterDeclarations(r Raiser) error {
for _, s := range MeshStreams() {
if s.Name == DeclarationsStream {
continue
}
if err := r.EnsureStream(s); err != nil {
return fmt.Errorf("asserting stream %s: %w", s.Name, err)
}
}
return AssertMeshConsumers(r)
}
-115
View File
@@ -1,115 +0,0 @@
package buscompose
import (
"time"
"github.com/nats-io/nats.go/jetstream"
)
// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the
// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two
// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the
// grants part (CreateStreamGrants grants no update).
// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options
// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape
// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the
// mesh's caps, file storage and one replica.
func BucketStreamConfig(b Bucket) jetstream.StreamConfig {
history := b.History
if history < 1 {
history = 1
}
age := time.Duration(b.TTLSeconds) * time.Second
dupes := 2 * time.Minute
if age > 0 && age < dupes {
dupes = age
}
cfg := jetstream.StreamConfig{
Name: "KV_" + b.Bucket(),
Description: b.Why(),
Subjects: []string{"$KV." + b.Bucket() + ".>"},
MaxMsgsPerSubject: int64(history),
MaxBytes: StateMaxBytes,
MaxAge: age,
MaxMsgSize: StateMaxValueBytes,
Storage: jetstream.FileStorage,
Replicas: 1,
AllowRollup: true,
DenyDelete: true,
Duplicates: dupes,
MaxMsgs: -1,
MaxConsumers: -1,
AllowDirect: true,
Discard: jetstream.DiscardNew,
}
if b.KeyTTL {
cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL
}
return cfg
}
// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker
// StreamConfigFor of ModuleStream.Stream).
func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig {
s := m.Stream()
cfg := jetstream.StreamConfig{
Name: s.Name,
Subjects: s.Subjects,
Retention: jetstream.LimitsPolicy,
MaxAge: time.Duration(s.MaxAge) * time.Second,
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
Description: s.Why,
AllowDirect: s.Direct,
AllowMsgTTL: s.AllowMsgTTL,
}
if s.Retention == RetentionWorkQueue {
cfg.Retention = jetstream.WorkQueuePolicy
}
if s.Restricted || s.WorkQueue {
cfg.Metadata = map[string]string{}
if s.Restricted {
cfg.Metadata[RestrictedMetadata] = "true"
}
if s.WorkQueue {
cfg.Metadata[WorkQueueMetadata] = "true"
}
}
if s.MaxBytes > 0 {
cfg.MaxBytes = s.MaxBytes
}
if s.DiscardNew {
cfg.Discard = jetstream.DiscardNew
}
if s.DuplicatesSeconds > 0 {
cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second
}
return cfg
}
// ConsumerConfig is a pull consumer as the controller's EnsureConsumer creates it (mesh-controller internal/broker
// JetStream.EnsureConsumer, a consumer not there yet): its durable name, explicit acks, its ack wait, max deliver and max
// ack pending, its description, one filter or several, and from the stream's end when it is made FromNow. The grants part
// creates a module's and a process's consumers with it from the lease write (novox/hq ADR 0561 §6): a create alone,
// never an update of one the bus holds. A push consumer or a queue group is the controller's own and never made here.
func ConsumerConfig(c Consumer) jetstream.ConsumerConfig {
cfg := jetstream.ConsumerConfig{
Durable: c.Name,
AckPolicy: jetstream.AckExplicitPolicy,
AckWait: time.Duration(c.AckWaitSeconds) * time.Second,
MaxDeliver: c.MaxDeliver,
MaxAckPending: c.MaxAckPending,
Description: c.Why,
}
switch len(c.Filters) {
case 0:
case 1:
cfg.FilterSubject = c.Filters[0]
default:
cfg.FilterSubjects = c.Filters
}
if c.FromNow {
cfg.DeliverPolicy = jetstream.DeliverNewPolicy
}
return cfg
}
-46
View File
@@ -1,46 +0,0 @@
package buscompose
import (
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them.
func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"})
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
WorkQueue: true, Duplicates: 1})
for _, row := range []struct {
what string
got, want any
}{
{"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"},
{"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"},
{"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)},
{"bucket cap", plain.MaxBytes, int64(StateMaxBytes)},
{"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)},
{"bucket duplicate window", plain.Duplicates, 2 * time.Minute},
{"bucket discards new", plain.Discard, jetstream.DiscardNew},
{"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true},
{"bucket without key ttl", plain.AllowMsgTTL, false},
{"history", keyed.MaxMsgsPerSubject, int64(5)},
{"age", keyed.MaxAge, time.Minute},
{"duplicate window under the age", keyed.Duplicates, time.Minute},
{"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL},
{"key ttl", keyed.AllowMsgTTL, true},
{"queue retention", queue.Retention, jetstream.WorkQueuePolicy},
{"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"},
{"queue age", queue.MaxAge, 7 * 24 * time.Hour},
{"queue cap", queue.MaxBytes, int64(32 << 20)},
{"queue discards new", queue.Discard, jetstream.DiscardNew},
{"queue duplicates", queue.Duplicates, time.Hour},
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
} {
if row.got != row.want {
t.Errorf("%s: %v, want %v", row.what, row.got, row.want)
}
}
}
-155
View File
@@ -1,155 +0,0 @@
package buscompose
import "testing"
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547
// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user
// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name.
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
records := Records{
Nodes: []string{"novox", "ace"},
Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": {
{Module: "nats", SnapshotsTheBus: true},
// mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part
// alone, whatever another module's reads say; the controller's copy grants any module that declares it.
{Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}},
{Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}},
{Module: "mesh-delivery-builder",
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
{Module: RuntimeModule},
// The node-engine's manifest as a placement names it (issue 774, met twice on 2026-10-30): no account of its
// own, so the runner would carry it, and its state is the node's to write alone.
{Module: EngineModule, NoAccount: true, Consumes: []string{"restic.backed-up"},
State: []Bucket{{Module: EngineModule, Name: "health"}, {Module: EngineModule, Name: EngineActsState}}},
}},
// The node-engine's own manifest, placed on every node though no node is assigned it (novox/hq ADR 0563 §2, §3).
Engine: &Declared{Module: EngineModule, State: []Bucket{{Module: EngineModule, Name: EngineActsState}}},
}
users, err := Users(records)
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Module == EngineModule {
t.Errorf("the node-engine's manifest composed a user of its own: %s", u.Username())
}
if u.Kind == KindNode || u.Kind == KindNodeTools {
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
perms[u.Username()] = p
continue
}
if u.Kind != KindModule {
continue
}
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
perms[u.Module] = p
}
for _, row := range []struct {
who, subject string
may bool
}{
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true},
{GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true},
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true},
{GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
// Research 072 row 33: a runtime reads its membership from the grants part's state, never the assignments stream.
{"mesh-delivery-builder", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.novox.mesh-delivery-builder", false},
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
// The node-engine's acts: created by the grants part, written by each node's engine under its own keys alone.
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-host_acts", true},
{"node.ace", "$JS.API.STREAM.CREATE.KV_mesh-host_acts", false},
{"node.ace", "$KV.mesh-host_acts.ace.act.hand-over-1", true},
{"node.ace", "$KV.mesh-host_acts.ace.open.hand-over.abc", true},
{"node.ace", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.ace.ask.hand-over-1", true},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-host_acts.$KV.mesh-host_acts.novox.ask.hand-over-1", false},
{"novox.node-tools", "$KV.mesh-host_acts.novox.act.hand-over-1", false},
// The runner never carries the engine (issue 774): its health report is the node's own key alone.
{"novox.node-tools", "$KV.mesh-host_health.novox", false},
{"novox.node-tools", "$KV.mesh-host_acts.novox.ask.x", false},
{"node.novox", "$KV.mesh-host_health.novox", true},
{"node.novox", "$KV.mesh-host_health.ace", false},
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
{GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.declaration-files-novox.C.abc", false},
{GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false},
{"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false},
// The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write.
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true},
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false},
{"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false},
{"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false},
{"node.novox", "$O.mesh-grants-user-list.C.abc", false},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false},
{"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false},
{GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true},
{"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
{"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
} {
if got := MayPublish(perms[row.who], row.subject); got != row.may {
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
}
}
// Issue 1790 (2026-10-26): a module hears the bus create each bucket it may bind, its own and those it reads, so a
// state missing at its start is bound when it appears; no other bucket's, and no other advisory.
for _, row := range []struct {
who, subject string
may bool
}{
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", true},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", false},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.LOG_mesh-delivery-builder_builds", false},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.DELETED.KV_mesh-delivery-builder_steps", false},
} {
if got := MaySubscribe(perms[row.who], row.subject); got != row.may {
t.Errorf("%s may hear %s: %v, want %v", row.who, row.subject, got, row.may)
}
}
// A name that is no plain token is never granted, and a module other than the grants part is never granted one.
if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 {
t.Errorf("grants for wildcard names: %v", got)
}
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}})
if err != nil {
t.Fatal(err)
}
if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") {
t.Error("a module other than the grants part was granted a create")
}
// The writers table refuses the grants part an update, and a create of a stream it was not given.
g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}}
if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil {
t.Errorf("the declared create refused: %v", err)
}
for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} {
if CheckWriters(g, []string{refused}) == nil {
t.Errorf("%s was not refused", refused)
}
}
}
-111
View File
@@ -1,111 +0,0 @@
package buscompose
import (
"context"
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/nats-io/nats.go/jetstream"
)
// The object buckets a declaration's large files travel in (novox/hq ADR 0486, issues 1696 and 1697): **one bucket for each
// node**, `declaration-files-<node>`.
//
// **The controller alone writes them, and a node-engine reads an object by its name from its own bucket** (ADR 0486 §2): its
// account may neither list the bucket nor write it, no account may touch another node's, and no other account has any access.
// Why a bucket each: a subject pattern cannot tell a name from a wildcard. A grant of `...M.*` matches a publish to the literal
// subject `...M.*` too (probed on nats-server 2.11.17 by the review of this issue: a node created a consumer filtered on
// `$O.<bucket>.C.*` and read every object of the bucket), so in one shared bucket a node could read every other node's files.
// With a bucket each, the same trick reads only the node's own files, which are the files of its own declaration.
//
// A bucket of the bus's object store is a stream, OBJ_<bucket>, whose messages are the objects' metadata
// (`$O.<bucket>.M.<base64 of the name>`) and chunks (`$O.<bucket>.C.<nuid>`).
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
const DeclarationFilesBucket = declaration.FileBucketPrefix
// DeclarationFilesBucketOf is the bucket a node's files are in: mesh-sdk's FileBucketFor, the one place the name is composed. A
// node name it refuses (one that could carry a subject wildcard or separator into a grant) is an error, never a bucket.
func DeclarationFilesBucketOf(node string) (string, error) { return declaration.FileBucketFor(node) }
// DeclarationFilesStreamOf is the stream under a node's bucket: mesh-sdk's FileStreamFor.
func DeclarationFilesStreamOf(node string) (string, error) { return declaration.FileStreamFor(node) }
// DeclarationFilesMaxBytes is a bucket's size: the objects are immutable and regenerable, and one node's hold well under a hundred
// megabytes.
const DeclarationFilesMaxBytes = 1 << 30
// DeclarationFilesKeptFor is how long an object stays after no node's latest declaration names it (ADR 0486 §3).
const DeclarationFilesKeptFor = 24 * time.Hour
// DeclarationFilesWrites is the one pattern the controller may publish to write a node's bucket: every object's metadata and
// chunks. The server's API for the stream is the controller's `$JS.API.>`.
func DeclarationFilesWrites(node string) (string, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return "", err
}
return "$O." + bucket + ".>", nil
}
// DeclarationFilesReads is what a node-engine's account may publish to read an object of its own bucket by its name, and nothing
// more. The set is the one node-engine's own call (mesh-host internal/link FileObject) makes, for B the node's bucket and S its
// stream (issue 1697, notes 4 and 5):
//
// - the object's metadata, by a direct get of the last message of the one subject that names it. **Only the suffixed
// form**: the bare `$JS.API.DIRECT.GET.S` takes a sequence number and would read any message of the stream.
// - the ordered consumer on that object's chunks: `$JS.API.CONSUMER.CREATE.S.<name>.$O.B.C.<nuid>`. **Not `S.>`**:
// a consumer filtered on `$O.B.M.>` would read every object's name.
// - the flow-control replies of that consumer, without which a large object stalls at the flow-control window.
// - the consumer's deletion, best effort: the consumer's inactivity threshold removes it if this is refused.
//
// **Never granted:** STREAM.INFO (a `subjects_filter` of `>` lists every name), STREAM.MSG.GET (any message by its sequence), a
// publish to `$O.B.>` (a write), and any subject of another node's stream. A literal `*` in a subject matches the grant's `*`: it
// reaches the objects of this node's own bucket and no other (above).
func DeclarationFilesReads(node string) ([]string, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return nil, err
}
stream, err := DeclarationFilesStreamOf(node)
if err != nil {
return nil, err
}
return []string{
"$JS.API.DIRECT.GET." + stream + ".$O." + bucket + ".M.*",
"$JS.API.CONSUMER.CREATE." + stream + ".*.$O." + bucket + ".C.*",
"$JS.API.CONSUMER.DELETE." + stream + ".*",
"$JS.FC." + stream + ".>",
}, nil
}
// EnsureDeclarationFiles is the same on a JetStream the caller holds.
func EnsureDeclarationFiles(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
for _, node := range nodes {
cfg, err := DeclarationFilesConfig(node)
if err != nil {
return err
}
if _, err := js.CreateOrUpdateObjectStore(ctx, cfg); err != nil {
return fmt.Errorf("asserting the object bucket %s: %w", cfg.Bucket, err)
}
}
return nil
}
// DeclarationFilesConfig is a node's bucket as the controller asserts it.
func DeclarationFilesConfig(node string) (jetstream.ObjectStoreConfig, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return jetstream.ObjectStoreConfig{}, err
}
return jetstream.ObjectStoreConfig{
Bucket: bucket,
Description: "the contents of the files over 64 KiB of the declaration of " + node + ", each under the hex of its sha256 " +
"digest (novox/hq ADR 0486): written by the controller alone, read by the node-engine of that node by name, removed " +
"a day after the node's latest declaration no longer names it",
MaxBytes: DeclarationFilesMaxBytes,
Storage: jetstream.FileStorage,
}, nil
}
-381
View File
@@ -1,381 +0,0 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// Streams and consumers derived from what modules declare.
//
// The mesh's own four exist before any module does (streams.go). Everything here is the other
// half: a seat's stream comes into being when the module declaring it is **registered**, and a
// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to
// the foundation set. Neither has happened at genesis.
//
// All of it is a pure function of declarations. The controller is still the only writer; this is
// only what it writes.
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
// declares what it reacts to, never how delivery works, so it does not name these and cannot
// misconfigure them.
type Consumer struct {
Name string
Stream string
// Filters are the subjects this consumer receives. One consumer per module with several
// filters, rather than one per consumed event: its ack subject is derived from its name, and
// a module with five consumers would need five ack permissions to ack its own deliveries.
Filters []string
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
Queue string
// Push asks the server to deliver to a subject rather than wait to be pulled.
//
// For the mesh's own consumer, where the controller wants every message to arrive in the one
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
// them over, and a loop that acts on one message at a time is the property the store window
// depends on. A queue group implies this, because a group has nothing to pull from.
Push bool
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
TakesOverFrom string
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
DeliverSubject string
// AckWaitSeconds before an unacknowledged delivery is redelivered.
AckWaitSeconds int
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
MaxDeliver int
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
// the server's default, which is many. **One, for a consumer handled one at a time**
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
MaxAckPending int
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
// holds, replays every merge and every build of that week as if it had just happened. A consumer
// that exists keeps where it is, whatever this says; only its making is decided here.
FromNow bool
Why string
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
// Not a property of the consumer on the bus: nothing here is sent to the server.
Resettable string
}
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
// the module holding it, because the holder can change and the queued work must not care — which
// is the whole reason a caller addresses a seat instead of a module.
func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) }
// SeatStreams is one work queue per declared seat, created when the declaring module is
// registered rather than when it is assigned.
//
// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a
// holder appears, so installing the telegram module a week after something started sending to it
// flushes the backlog instead of having lost it. A stream created at assignment would make "the
// holder is not here yet" mean "your messages are gone".
func SeatStreams(seats []DeclaredSeat) []Stream {
sorted := append([]DeclaredSeat(nil), seats...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var out []Stream
for _, s := range sorted {
if len(s.Accepts) == 0 {
// A seat that only emits and serves needs no stream: its events ride EVENTS and its
// tools are core request/reply, which is never persisted.
continue
}
retain := s.RetainSeconds
if retain == 0 {
retain = 7 * 24 * 60 * 60
}
out = append(out, Stream{
Name: seatStreamName(s.Name),
Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
Retention: RetentionWorkQueue,
MaxAge: retain,
Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+
"queues while nobody does", s.Name),
})
}
return out
}
// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this
// package stays free of the catalogue's own types — the same reason the host mirrors the
// contracts instead of importing the sdk.
type DeclaredSeat struct {
Name string
Accepts []string
// Emits are the verbs the seat's holder publishes under the seat's own name. An event about a
// role belongs here rather than in the holder's namespace, because the name then outlives
// whoever fills it (novox/hq ADR 0121, 04-ISSUES/127).
Emits []string
// Serves are the verbs the holder answers, request and reply.
Serves []string
RetainSeconds int
// PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here.
PulledByKind bool
}
// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes
// to nothing and needs none.
//
// One per module, with every consumed subject as a filter, because its ack permission is derived
// from its name: a module with a consumer per event would need an ack permission per consumer,
// and the permission list would stop being derivable from the declaration.
func ConsumerFor(p Principal) (Consumer, bool) {
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
// a role was missing here, so the one module that does it got no consumer at all: it started,
// connected, and its graph stayed empty with nothing anywhere reporting why.
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
hearsAStream := false
for _, h := range p.Hears {
if len(h.Filters) > 0 {
hearsAStream = true
}
}
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
return Consumer{}, false
}
perms, err := PermissionsFor(p)
if err != nil {
return Consumer{}, false
}
// Events, wherever they live: a module's own namespace, and the namespace of any role it watches
// (novox/hq ADR 0121). Tool subjects and inboxes are subscribed directly and are not a consumer's
// business, which is why this is a filter and not the whole list.
var filters []string
for _, s := range perms.Subscribe {
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
filters = append(filters, s)
}
}
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
if len(filters) == 0 && !hearsAStream {
return Consumer{}, false
}
sort.Strings(filters)
// And the events given up on and delivered again to this consumer alone (novox/hq issue 330).
filters = append(filters, AgainFilter(consumerDurable(p)))
return Consumer{
Name: consumerDurable(p),
Stream: consumerStream(p),
Filters: filters,
AckWaitSeconds: 30,
MaxDeliver: 5,
// From now, as the controller's own consumers have been since issue 248: a module made on a stream that
// keeps a week replays none of it. What a part needs of the present it reads from its owner
// (novox/hq ADR 0443 §1, §3). A consumer that exists keeps its place whatever this says.
FromNow: true,
Why: "what " + p.Module + " declared it consumes; after max-deliver it gives an event up, and the " +
"controller keeps it in DEAD_LETTERS until a person delivers it again or drops it",
}, true
}
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
type ModuleConsumer struct {
Node, Module string
Consumer Consumer
}
// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and
// each module a runtime carries — a carried module with no account of its own is no user (novox/hq
// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the
// module's behalf (ADR 0198). One per module and node, whichever of the two named it first.
func ConsumersOf(users []Principal) []ModuleConsumer {
var out []ModuleConsumer
seen := map[string]bool{}
add := func(p Principal) {
key := p.Node + "/" + p.Module
if seen[key] {
return
}
c, needed := ConsumerFor(p)
// **A module with a work-queue consumer of its own needs no other reason to be visited here**
// (novox/hq ADR 0436 §1, §2, §6): mesh-mail's sender consumes and watches nothing of its own, so
// ConsumerFor alone would have found nothing needed and skipped it before its one queue consumer
// was ever composed.
if !needed && len(p.Queues) == 0 {
return
}
seen[key] = true
if needed {
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
// And one on each module stream it hears (novox/hq ADR 0363 §5).
for _, h := range p.Hears {
if len(h.Filters) == 0 {
continue
}
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
}
}
// And the one durable consumer of each of its own work-queue streams, for the one process its
// bus.queues names (novox/hq ADR 0436 §1, §2, §6).
for _, q := range p.Queues {
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: QueueConsumer(p, q)})
}
}
for _, p := range users {
if p.Kind == KindModule {
add(p)
}
}
for _, p := range users {
if p.Kind != KindNodeTools {
continue
}
for _, d := range p.Carries {
// **A carried module's work-queue consumer is still the controller's to make** (novox/hq ADR
// 0436 §1, §2, §6), the same reason its ordinary consumer is (ADR 0198): mesh-mail has no
// account of its own (NoAccount) and is carried here, but its process does, and the consumer
// it reads through must exist and survive PruneHeard whether or not the module itself speaks.
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues})
}
}
return out
}
// AccountConsumers are the durable consumers an account needs (novox/hq ADR 0561 §6): of ConsumersOf, each module's on
// EVENTS and each process's on its own work queue, never one on a module stream it hears (those the controller makes with
// the stream's grant). The grants part creates them from the lease write, with these configurations.
func AccountConsumers(users []Principal) []ModuleConsumer {
queues := map[string]bool{}
for _, p := range users {
for _, q := range p.Queues {
queues[q.Stream] = true
}
for _, d := range p.Carries {
for _, q := range d.Queues {
queues[q.Stream] = true
}
}
}
var out []ModuleConsumer
for _, mc := range ConsumersOf(users) {
if mc.Consumer.Stream == consumerStream(Principal{Kind: KindModule}) || queues[mc.Consumer.Stream] {
out = append(out, mc)
}
}
return out
}
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
//
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*,
// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a
// holder per machine, and all of them take from this one consumer, so the work is shared without
// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the
// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by
// a holder that has just become free. Which is also what ends the race issue 186 describes — asks
// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth
// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive
// (stillWorking); the ack wait is for a holder that died.
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
if len(seat.Accepts) == 0 || seat.PulledByKind {
// A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one
// worker filtered on every accept would overlap them on the work queue.
return Consumer{}, false
}
return Consumer{
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
Stream: seatStreamName(seat.Name),
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
AckWaitSeconds: 60,
MaxDeliver: 5,
// As many in flight as there are holders working, which pulling bounds by itself: a holder
// fetches one and fetches again only after it acknowledged. The server's default stands.
Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+
"and acknowledges after the work is done, so a crash mid-work redelivers rather than "+
"loses and an idle holder is the one that takes the next ask", module, node, seat.Name),
}, true
}
// NodeConsumer is the durable consumer a node reads its own declaration through.
//
// **Derived from a node existing, and created by the controller, because a host cannot create it.**
// A host's account may subscribe its own declaration subject and publish its own ack subject, and
// reaches no part of the JetStream API — which is correct (the controller is the only writer of
// consumer definitions, design 25 §3) and means the consumer must be waiting before the host binds
// to it. Named after the node, because the node's ack grant is `$JS.ACK.NODES.<node>.>` and a
// consumer named anything else is one the host cannot acknowledge a delivery from.
//
// **No max-deliver, and a long ack wait.** A declaration is settled only after the node has applied
// it and reported, which is minutes on a machine pulling images; and a declaration the mesh cannot
// get a node to accept is not one to give up on, because the stream keeps only the newest per node
// anyway — so there is exactly one message per node to redeliver, for as long as that node is away.
func NodeConsumer(node string) Consumer {
return Consumer{
Name: node,
Stream: "NODES",
Filters: []string{"mesh.node." + node + ".declare"},
Push: true,
AckWaitSeconds: 300,
Why: "how " + node + " hears what it should be; last-per-subject, so a node that was away " +
"gets exactly the current declaration and nothing older",
}
}
// AssertNodeConsumers brings every known node's declaration consumer into being.
//
// Asserted on start as well as created at enrolment, for the reason the streams are: a mesh raised
// from a restored backup, or one whose bus was recreated, has node records and no consumers, and a
// node whose consumer is missing hears nothing while everything else about it looks correct.
func AssertNodeConsumers(e Ensurer, nodes []string) error {
for _, n := range nodes {
if err := e.EnsureConsumer(NodeConsumer(n)); err != nil {
return fmt.Errorf("asserting how %s hears its declaration: %w", n, err)
}
}
return nil
}
// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and
// every derived one.
//
// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10:
// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a
// registration that fails. Catching it here names both streams, before a half-applied mesh does.
func AllOverlaps(seats []DeclaredSeat) []string {
all := append(MeshStreams(), SeatStreams(seats)...)
seen := map[string]string{}
var clashes []string
for _, s := range all {
for _, subject := range s.Subjects {
if first, ok := seen[subject]; ok {
clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject))
continue
}
seen[subject] = s.Name
}
}
sort.Strings(clashes)
return clashes
}
// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot,
// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own.
func upperSnake(s string) string {
out := []rune(s)
for i, r := range out {
switch {
case r >= 'a' && r <= 'z':
out[i] = r - 32
case r == '-' || r == '.':
out[i] = '_'
}
}
return string(out)
}
-12
View File
@@ -1,12 +0,0 @@
// Package buscompose is the bus's composer: who may say what on the bus, as the user list and the server configuration
// the bus loads, derived from the mesh's records (Users, WithPasswords, ComposeAccounts, Compose), with the grants,
// streams, state and logs it composes them from.
//
// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at
// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5) and its grant to create the streams manifests declare (issue 1790): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go
// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in
// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so
// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers
// holds this copy to lists the controller's own code wrote from the same fixtures. A change to what is granted is made
// here and, while the controller composes, in the controller's copy too, never in one alone.
package buscompose
-82
View File
@@ -1,82 +0,0 @@
package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
)
// The node seat node-engine (novox/hq ADR 0563 §1): every node's engine holds it on its own bus account, and no module
// claims it. Its verbs and events are the catalogue's (catalogue.EngineVerbs), read here rather than copied.
// EngineSeat is the seat.
const EngineSeat = catalogue.EngineSeat
// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the
// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys
// `<node>.act.<id>`, `<node>.open.<act>.<digest>` and `<node>.ask.<id>`, each written by that node's engine alone.
const (
EngineModule = "mesh-host"
EngineActsState = "acts"
)
// EngineActsBucket is that state's bucket.
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
// EnginePackagesBucket is the engine's state of its node's declared packages (novox/hq ADR 0565 clause 6): one key, the
// node's name, written by that node's engine alone.
var EnginePackagesBucket = BucketName(EngineModule, delivery.EnginePackagesState)
// EngineDataBucket is the engine's state of its node's placed modules' data items (novox/hq ADR 0577 clause 1): one
// key `<node>.<module>` per module, written by that node's engine alone.
var EngineDataBucket = delivery.EngineDataBucket
// EnginePackagesGrants is what a node's engine publishes to keep its node's key of the packages bucket: the key alone,
// and its read.
func EnginePackagesGrants(node string) []string {
return append([]string{"$KV." + EnginePackagesBucket + "." + node}, readGrants(EnginePackagesBucket, node)...)
}
// engineGrants is what a node's engine is granted to hold its node seat and to ask the operator (ADR 0563 §1, §3): it
// answers its own node's verbs (the caller-named subjects are added beside them with every principal's), says the
// seat's events about its node, answers discovery for the seat, asks who answers what (the login shell's execute, ADR
// 0563 §6), and asks the operator under asks.EngineAsker(node) on the router's seat as its holder declares it, as any
// user of that seat is granted.
//
// Its verbs and events are granted as any verb and any event of the seat on its own node, not as the contract's list:
// the list is compiled into whichever mesh-sdk the grants part pins, so a verb the engine gained before the grants part
// was released was refused on every node and the engine reopened its link every few minutes (2026-10-31, the optional
// verb composition of mesh-host #184). As a module answers `mesh.mod.<module>.tool.>`, nothing is given away: no other
// principal may subscribe this seat's subjects of this node, and a caller is still granted each verb by name.
func engineGrants(p Principal) (pub, sub []string) {
sub = append(sub, "mesh.seat."+EngineSeat+".tool.*."+p.Node)
pub = append(pub, "mesh.seat."+EngineSeat+".event.*."+p.Node)
sub = append(sub, announcing(EngineSeat)...)
pub = append(pub, discovering()...)
tp, ts := seatTrafficGrants(SeatTrafficOf(asks.EngineAsker(p.Node), nil, p.Uses, nil))
// And its acts and asks, under its own node's keys alone; never the bucket's creation, which is the grants part's.
if own, err := NodeKeysGrants(EngineActsBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And the provisions its node's modules consume, its own node's keys alone, read (novox/hq ADR 0570 clause 6: the
// consuming engine checks the vault's name and generation of what it is handed).
if safeSubject.MatchString(p.Node) {
pub = append(pub, readGrants(provisions.ProvisionsBucket, p.Node+".>")...)
}
// And its node's own health report, its key `<node>` alone (issue 774, the operator's alert table): what the watchman
// reads for the machine's alerts.
if safeSubject.MatchString(p.Node) {
pub = append(pub, append([]string{"$KV." + delivery.NodeHealthBucket + "." + p.Node},
readGrants(delivery.NodeHealthBucket, p.Node)...)...)
}
// And its node's placed modules' data items, its own node's keys alone (ADR 0577 clause 1).
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
if safeSubject.MatchString(p.Node) {
pub = append(pub, EnginePackagesGrants(p.Node)...)
}
return append(pub, tp...), append(sub, ts...)
}
-119
View File
@@ -1,119 +0,0 @@
package buscompose
// The fixtures the copied bus composer is held to (issue 1377, ADR 0547 §5): the same source is compiled into the
// controller's internal/broker to write the expected lists, and into mesh-sdk's buscompose to compare against them.
// UserListFixture is one mesh's records, and the hashes its accounts carry.
type UserListFixture struct {
Name string
Records Records
// Hashless are users given no hash, so they are left out and named.
Hashless []string
}
func fixtureTelegramSeat() Seat {
return Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
}
// UserListFixtures are the meshes, from the controller's own tests' records outward.
func UserListFixtures() []UserListFixture {
return []UserListFixture{
{Name: "empty"},
{Name: "the controller's someRecords", Records: Records{
Nodes: []string{"two", "one"},
Assigned: map[string][]Declared{
"one": {{Module: "telegram", Serves: []string{"status"}}},
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
},
Enrolling: []string{"three"},
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
}},
{Name: "seats, consumers, people and a hashless user", Hashless: []string{"two.audit"}, Records: Records{
Nodes: []string{"one", "two"},
Assigned: map[string][]Declared{
"one": {
{Module: "telegram", Holds: []Seat{fixtureTelegramSeat()}, Serves: []string{"status"}},
{Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}},
{Module: "mesh-watchman"},
{Module: "mesh-grants", Emits: []string{"membership-changed"}},
},
"two": {
{Module: "shop", Uses: []Seat{fixtureTelegramSeat()}, Emits: []string{"order.placed"}},
{Module: "audit", Consumes: []string{"shop.order.placed"}},
},
},
People: map[string][]string{"ada": {"*"}, "bob": {"shop.status"}},
Interchangeable: map[string]bool{"shop": true},
}},
{Name: "state, logs, streams, queues and processes", Records: Records{
Nodes: []string{"ace", "novox"},
Assigned: map[string][]Declared{
"novox": {
{Module: "mesh-delivery-builder", Emits: []string{"built"},
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5},
{Module: "mesh-delivery-builder", Name: "presence", PerMachine: true, KeyTTL: true, TTLSeconds: 60}},
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds", MaxMiB: 64}},
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "asks", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, WorkQueue: true}},
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
Processes: []ProcessPrincipal{{Process: "worker", Emits: []string{"built"},
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
State: []string{"steps"}, Invokes: []string{"gitea.gitea_commit"}}},
Invokes: []string{"gitea.gitea_commit"},
},
{Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"},
KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}},
// The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790).
{Module: "mesh-grants", Emits: []string{"membership-changed"},
State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}},
},
"ace": {
{Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}},
{Module: "silent", NoAccount: true},
},
},
RootFree: map[string]bool{"ace": true},
}},
}
}
// HashOf is the stand-in hash a fixture's user carries.
func HashOf(user string) string {
h := "$2a$11$"
for len(h) < 29 {
h += user
}
return h[:29] + "fixturefixturefixturefixture00"
}
// ComposedFor is a fixture's user list as the composer writes it, and the users left out.
func ComposedFor(f UserListFixture) (string, []string, error) {
users, err := Users(f.Records)
if err != nil {
return "", nil, err
}
hashes := map[string]string{}
skip := map[string]bool{}
for _, u := range f.Hashless {
skip[u] = true
}
for _, u := range users {
if !skip[u.Username()] {
hashes[u.Username()] = HashOf(u.Username())
}
}
filled, missing := WithPasswords(users, hashes)
list, err := ComposeAccounts(filled)
return list, missing, err
}
// MembershipsFor is every membership a fixture's records issue, by `<node>.<module>`, as the composer makes them.
func MembershipsFor(f UserListFixture) map[string]Membership {
where := PlacementsOf(f.Records, f.Records.Interchangeable)
out := map[string]Membership{}
for node, ds := range f.Records.Assigned {
for _, d := range ds {
out[node+"."+d.Module] = MembershipFor(node, d, where)
}
}
return out
}
-212
View File
@@ -1,212 +0,0 @@
package buscompose
import (
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
)
// **The health holders' grants** (novox/hq ADR 0564 clauses 2, 5 and 8, Limits 2, 4 and 8; issue 1952 S5; ADR 0590).
//
// - Nobody reads mesh-healers' `would` (clause 8): what the healers would have done reaches no condition. Every
// observer's `findings`, the healers' `attempts`, the wants and the haves are read through `reads` as any announced
// state is; and no module writes another's state, so an observer can never write the keeper's buckets (clause 2).
// - The watchman makes its own consumer on DEAD_LETTER_NOTICES, by its own name, pulled, and reads whether the ended
// controller's consumer `controller` still stands; it may neither delete nor change that consumer (clause 5).
// - The bus module's tools run on an account of their own, declared by their bundle's `bus` with `removes`
// (catalogue.BusAccounts), which may remove only keys of the node-engine's acts bucket (Limits 4) and the ended
// controller's leftovers (ADR 0590 clause 6), never the backup's account `broker`, which only reads (ADR 0235).
// HealersWould is what mesh-healers would have done while a controller was placed: read by nobody (ADR 0564 clause 8).
var HealersWould = health.HealersModule + "." + health.WouldState
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
var ControllerLeftoverConsumers = []string{
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
}
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
func ControllerLeftoverConsumersOn(nodes []string) []string {
out := slices.Clone(ControllerLeftoverConsumers)
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "EVENTS."+n+"_"+EngineModule)
}
}
return out
}
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
"lease", "predefined", "rollouts", "states-announced"}
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
func ControllerLeftoverStreams(nodes []string) []string {
out := []string{"CONTROL", AssignmentsStream}
for _, b := range ControllerLeftoverBuckets {
out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b))
}
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
}
}
return out
}
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
// manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
case catalogue.RemovesControllerLeftovers:
for _, c := range ControllerLeftoverConsumersOn(nodes) {
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
}
for _, s := range ControllerLeftoverStreams(nodes) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
}
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
}
}
return out, nil
}
// removesLeftover is an account declared to remove the ended controller's leftovers deleting one of its streams: the
// writers table's one exemption from stream definitions being the grants part's (ADR 0590 clause 6).
func removesLeftover(p Principal, pattern string) bool {
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.DELETE.")
return ok && p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesControllerLeftovers) &&
slices.Contains(ControllerLeftoverStreams(p.FilesFor), name)
}
// removesEngineActs is an account declared to remove keys of the node-engine's acts bucket: the writers table's one
// exemption from a node's engine being the only writer of its keys, for removal (ADR 0564 Limits 4).
func removesEngineActs(p Principal, pattern string) bool {
return p.Kind == KindProcess && slices.Contains(p.Removes, catalogue.RemovesEngineActs) && pattern == "$KV."+EngineActsBucket+".>"
}
// WatchmanDeadLetterConsumer is the watchman's own consumer on DEAD_LETTER_NOTICES, made only once the lease names no
// controller and `controller` is gone (ADR 0564 clause 5).
const WatchmanDeadLetterConsumer = WatchmanModule
// WatchmanDeadLetterGrants are what the watchman may publish for the dead letters in L4 (ADR 0564 clause 5), at least
// privilege: its own pulled consumer on DEAD_LETTER_NOTICES (made, read, pulled, acknowledged); whether `controller`
// still stands (read only); a read of DEAD_LETTERS (its information and one kept message); a copy kept
// (`mesh.events.dead.>`); and on each stream a notice can name (sources: EVENTS, the seats' work queues and the
// modules' own), its information, for a source's age (ADR 0376), and the message given up on, each stream by its name.
// Never a wildcard over the streams and never a bucket's or an object store's stream: a read of any message would read
// every bucket (the lead, 2026-10-29, on issue 1952 S5). Delivering again and dropping stay refused in L4, so it may
// neither delete a message nor publish `mesh.again.>`.
func WatchmanDeadLetterGrants(sources []string) []string {
own := DeadLetterNoticesStream + "." + WatchmanDeadLetterConsumer
out := []string{
"$JS.API.STREAM.INFO." + DeadLetterNoticesStream,
"$JS.API.CONSUMER.INFO." + DeadLetterNoticesStream + "." + ControllerName,
"$JS.API.CONSUMER.CREATE." + own,
"$JS.API.CONSUMER.INFO." + own,
"$JS.API.CONSUMER.MSG.NEXT." + own,
"$JS.ACK." + own + ".>",
"$JS.API.STREAM.INFO." + DeadLettersStream,
"$JS.API.STREAM.MSG.GET." + DeadLettersStream,
deadLetterPrefix + ">",
// And the provisioner clients' reports, the newest per subject read at its start (S21, ADR 0362 §3).
"$JS.API.STREAM.INFO." + ProvAppliedStream,
"$JS.API.STREAM.MSG.GET." + ProvAppliedStream,
"$JS.API.DIRECT.GET." + ProvAppliedStream + ".>",
}
for _, s := range DeadLetterSources(sources) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.MSG.GET."+s)
}
return out
}
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
// them (the lead, 2026-10-29).
func WatchmanHears() []string {
return []string{
"mesh.mod.power.event.*",
"mesh.mod.*.event.provisioner.failing",
"mesh.mod.*.event.provisioner.recovered",
"mesh.control.*.tools-alive",
"mesh.seat.mesh-self-check.event." + health.EventChecked,
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
"mesh.mod.nats.event.bus.slow-consumer",
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
}
}
// DeadLetterSources are the streams of names a dead letter may come from, sorted, none twice: each a plain name, never a
// bucket's (KV_) or an object store's (OBJ_) stream, the dead letters' own two streams, or a pattern.
func DeadLetterSources(names []string) []string {
seen := map[string]bool{}
var out []string
for _, n := range names {
switch {
case seen[n], !safeSubject.MatchString(n), strings.HasPrefix(n, "KV_"), strings.HasPrefix(n, "OBJ_"),
n == DeadLettersStream, n == DeadLetterNoticesStream:
continue
}
seen[n] = true
out = append(out, n)
}
sort.Strings(out)
return out
}
// deadLetterSourcesOf are the streams the records' consumers give up on: EVENTS, the work queue of every seat that
// accepts work, and every module's and process's own work queue.
func deadLetterSourcesOf(r Records) []string {
out := []string{EventsStream}
for _, mods := range r.Assigned {
for _, d := range mods {
for _, s := range append(append([]Seat(nil), d.Holds...), d.Uses...) {
if len(s.Accepts) > 0 {
out = append(out, seatStreamName(s.Name))
}
}
for _, q := range d.Queues {
out = append(out, q.Stream)
}
for _, pr := range d.Processes {
for _, q := range pr.Queues {
out = append(out, q.Stream)
}
}
}
}
return DeadLetterSources(out)
}
// keepsDeadLetters is the watchman keeping the dead letters: a second writer of the copies beside the controller, which
// keeps them until its lease is handed away and the watchman after (ADR 0564 clause 5). Delivering again stays the
// controller's in L4.
func keepsDeadLetters(p Principal, pattern string) bool {
return p.Kind == KindModule && p.Module == WatchmanModule && pattern == deadLetterPrefix+">"
}
-242
View File
@@ -1,242 +0,0 @@
package buscompose
import (
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// A module's log on the bus (novox/hq ADR 0297): its record of operations, each entry appended under
// a key and kept as long as the log is.
//
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
// its stream and subjects, the controller creates it from the catalogue on every raise and from
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
// is gone is reported, as a bucket is. Only its owner appends to it. Another module reads it when its manifest's
// `reads` names it, `<module>.<log>` (novox/hq ADR 0571 clause 1; the lead, 2026-10-29): by direct gets, and through an
// ordered consumer of its own it creates and lets go of to read from a time or the newest per key and then follow new
// entries; never a durable consumer, never a write.
//
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
// is exactly what is sent; jetstream.go is the part that asks a server.
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
// when its module says nothing and at most.
const (
LogMaxEntryBytes = 256 * 1024
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
LogDefaultMiB = 1024
LogMostMiB = 8192
)
// A Log is one module's declared log as the bus holds it.
type Log struct {
Module string
Name string
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
MaxMiB int
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
MaxAgeDays int
}
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
const LogStreamPrefix = "LOG_"
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
// local name are each one token with no underscore, so two modules can never derive one stream.
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
// is on `<LogSubject>.<K>`.
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
// Stream is this log's stream name.
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
// Subject is this log's subject, without the key.
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
// MaxBytes is this log's cap in bytes.
func (l Log) MaxBytes() int64 {
mib := l.MaxMiB
if mib <= 0 {
mib = LogDefaultMiB
}
return int64(mib) * 1024 * 1024
}
// Why is carried into the server's description of the stream, so somebody reading the server's own
// state finds whose it is and why it is kept.
func (l Log) Why() string {
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
"it is data", l.Module, l.Name, l.Module)
}
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
// headers at most, that refuses a new entry when full rather than
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
// is how the runtime reads it.
func (l Log) Config() jetstream.StreamConfig {
return jetstream.StreamConfig{
Name: l.Stream(),
Description: l.Why(),
Subjects: []string{l.Subject() + ".>"},
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
MaxMsgs: -1,
MaxMsgsPerSubject: -1,
MaxBytes: l.MaxBytes(),
MaxMsgSize: LogMaxMessageBytes,
Discard: jetstream.DiscardNew,
AllowDirect: true,
DenyDelete: true,
DenyPurge: true,
Replicas: 1,
}
}
// logsIssuedFor is every log a module's code may reach, as its membership lists them: its own.
func logsIssuedFor(d Declared) []LogIssued {
var out []LogIssued
for _, l := range d.Logs {
if !safeSubject.MatchString(d.Module) || !safeSubject.MatchString(l.Name) {
continue
}
out = append(out, LogIssued{Name: l.Name, Stream: LogStreamName(d.Module, l.Name),
Subject: LogSubject(d.Module, l.Name), Writes: true})
}
return out
}
// logGrants is what a principal publishes to reach the logs its module keeps: for each, appending
// under the log's subjects, binding to its stream, and reading it directly. Nothing more — the
// runtime reads a log by direct gets alone and makes no consumer on it — and nothing of any other
// module's log, because a log is its owner's alone. Replies come on the principal's inbox, as for a
// bucket.
func logGrants(module string, names []string) []string {
if !safeSubject.MatchString(module) {
return nil
}
var out []string
for _, name := range names {
if !safeSubject.MatchString(name) {
continue
}
stream := LogStreamName(module, name)
out = append(out,
LogSubject(module, name)+".>",
"$JS.API.STREAM.INFO."+stream,
"$JS.API.DIRECT.GET."+stream,
"$JS.API.DIRECT.GET."+stream+".>")
}
return out
}
// DeclaredLogs is every log a module placed anywhere declares, as a read names it: `<module>.<log>`.
func DeclaredLogs(r Records) map[string]bool {
out := map[string]bool{}
for _, ds := range r.Assigned {
for _, d := range ds {
for _, l := range d.Logs {
out[d.Module+"."+l.Name] = true
}
}
}
return out
}
// withReadLogs is d with ReadLogs set: of its reads, those naming a log of logs.
func withReadLogs(d Declared, logs map[string]bool) Declared {
d.ReadLogs = nil
for _, r := range d.Reads {
if logs[strings.TrimSpace(r)] {
d.ReadLogs = append(d.ReadLogs, strings.TrimSpace(r))
}
}
return d
}
// readLogGrants is what a reader of other modules' logs publishes, for each `<module>.<log>` read: the stream's info,
// direct gets (an entry by sequence, the last of a key), and an ordered consumer of its own (created with any name and
// filter, pulled, read and deleted). No append, no purge, no delete of an entry: the log's stream refuses those anyway.
func readLogGrants(reads []string) []string {
var out []string
for _, r := range reads {
module, name, ok := strings.Cut(r, ".")
if !ok || !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
continue
}
stream := LogStreamName(module, name)
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream, "$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.MSG.NEXT."+stream+".*",
"$JS.API.CONSUMER.INFO."+stream+".*", "$JS.API.CONSUMER.DELETE."+stream+".*")
}
return out
}
// readLogsIssuedFor is every other module's log a module's code reads, as its membership lists them: by the read's
// name, never written.
func readLogsIssuedFor(d Declared) []LogIssued {
var out []LogIssued
for _, r := range d.ReadLogs {
module, name, _ := strings.Cut(r, ".")
out = append(out, LogIssued{Name: r, Stream: LogStreamName(module, name), Subject: LogSubject(module, name)})
}
return out
}
// logNames is the local names of a module's logs.
func logNames(logs []Log) []string {
out := make([]string, 0, len(logs))
for _, l := range logs {
out = append(out, l.Name)
}
return out
}
// A LogAsserter is the part of a JetStream connection log assertion needs. It has no way to remove a
// log, by design: nothing the mesh runs asks for one.
type LogAsserter interface {
// EnsureLog creates the log's stream if absent and brings its configuration to match if present,
// never deleting or recreating it.
EnsureLog(l Log) error
// LogStreams is every log stream on the server: every stream named with LogStreamPrefix.
LogStreams() ([]string, error)
}
// RaiseLogs asserts every declared log and answers the log streams on the server that nothing
// declares any more.
//
// **Those are reported, never removed** (novox/hq ADR 0297 §2, ADR 0201 §15–16): a log is a module's
// record, and a manifest edited, a module renamed or unassigned is an ordinary day's work that must
// not take a record with it. Removing one is a person's act, outside the mesh.
func RaiseLogs(a LogAsserter, logs []Log) (undeclared []string, err error) {
sorted := append([]Log(nil), logs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stream() < sorted[j].Stream() })
declared := map[string]bool{}
for _, l := range sorted {
if err := a.EnsureLog(l); err != nil {
return nil, fmt.Errorf("asserting %s's log %q: %w", l.Module, l.Name, err)
}
declared[l.Stream()] = true
}
names, err := a.LogStreams()
if err != nil {
return nil, fmt.Errorf("listing the bus's logs: %w", err)
}
for _, n := range names {
if strings.HasPrefix(n, LogStreamPrefix) && !declared[n] {
undeclared = append(undeclared, n)
}
}
sort.Strings(undeclared)
return undeclared, nil
}
-178
View File
@@ -1,178 +0,0 @@
package buscompose
import (
"slices"
"sort"
"strings"
)
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
//
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
// controller composes a membership for every module on every machine and publishes it to a subject
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
// grant is the same composition read the other way. The shape issued today is the shape the mesh
// already had, so nothing moves when a membership first arrives; only who decides it moves.
// Placements is where every module runs, for deciding which instance answers for the module.
type Placements struct {
// Nodes is each module's machines.
Nodes map[string][]string
// Interchangeable is each module whose definition says its instances are the same anywhere,
// so the module's plain subject is issued to all of them in one queue.
Interchangeable map[string]bool
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
Kinds []KindHeld
// Logs is every log a module placed anywhere declares, `<module>.<log>` (DeclaredLogs): a read naming one is issued
// as a log read (novox/hq ADR 0571 clause 1).
Logs map[string]bool
}
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
// plain subject: when it is the only instance, or when the definition says instances are
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
// that names none reaches nothing rather than the wrong store.
func (p Placements) AnswersForTheModule(module string) bool {
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
}
// MembershipFor composes one assignment's membership from what it declared and where everything
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
// grant itself is read from the membership — which is the next step, not this one.
func MembershipFor(node string, d Declared, where Placements) Membership {
own := "mesh.mod." + d.Module
m := Membership{
Node: node, Module: d.Module,
Emits: own + ".event.{event}",
Tools: own + ".tool.tools",
}
// This machine's address always; the module's when this instance answers for the module.
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
if where.AnswersForTheModule(d.Module) {
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
}
for _, s := range d.Holds {
for _, verb := range s.Serves {
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
}
}
m.State = stateIssuedFor(d, node)
m.Logs = append(logsIssuedFor(d), readLogsIssuedFor(withReadLogs(d, where.Logs))...)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
m.Waits = waitsIssuedFor(d)
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
if !s.Kinded {
continue
}
for _, k := range where.Kinds {
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
t.Kinds = append(t.Kinds, k)
}
}
}
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
m.SeatTraffic = &t
}
if len(d.Invokes) > 0 {
m.Reaches = map[string][]string{}
for _, t := range d.Invokes {
if t == "*" || strings.HasPrefix(t, "seat:") {
continue // every tool, or a role's: addressed by name, not resolved per instance
}
module, tool, ok := strings.Cut(t, ".")
if !ok {
continue
}
var reach []string
if where.AnswersForTheModule(module) {
reach = append(reach, "mesh.mod."+module+".tool."+tool)
}
nodes := append([]string{}, where.Nodes[module]...)
sort.Strings(nodes)
for _, n := range nodes {
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
}
m.Reaches[t] = reach
}
}
return m
}
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable, Logs: DeclaredLogs(r)}
for node, declared := range r.Assigned {
for _, d := range declared {
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
}
}
for _, nodes := range p.Nodes {
sort.Strings(nodes)
}
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
// placed nowhere, or on more than one machine, frees nothing.
var routerNodes []string
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
routerNodes = append(routerNodes, node)
}
}
}
}
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Kinded && s.Kind != "" {
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
}
}
}
}
sort.Slice(p.Kinds, func(i, j int) bool {
a, b := p.Kinds[i], p.Kinds[j]
if a.Seat != b.Seat {
return a.Seat < b.Seat
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.Node < b.Node
})
return p
}
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
const routerSeat = "operator-channel"
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
// account of its own — never one the machine's runtime carries as the operator's account — and only while
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
var out []string
for _, c := range declared {
if c == "verified-sender" && (runsAs == "" || !rootFree) {
continue
}
out = append(out, c)
}
return out
}
func kindListed(list []KindHeld, k KindHeld) bool {
for _, x := range list {
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
return true
}
}
return false
}
-343
View File
@@ -1,343 +0,0 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// A module's own stream on the bus (novox/hq ADR 0363).
//
// A module declares a stream as it declares a log: named locally, created by the controller from the
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
// EVENTS. Pure; the controller's raise asserts what is composed here.
// moduleStreamPrefix starts every subject a module's own stream takes.
const moduleStreamPrefix = "mesh.stream."
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
func ModuleStreamSubject(module, event string) string {
return moduleStreamPrefix + module + ".event." + event
}
// OnAModuleStream says a subject is one a module's own stream takes.
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
func WirePattern(local string) string {
parts := strings.Split(local, ".")
for i, p := range parts {
if p == "**" {
parts[i] = ">"
}
}
return strings.Join(parts, ".")
}
// A ModuleStream is one module's declared stream as the bus holds it.
type ModuleStream struct {
Module string
// Name is its name on the bus, `MAIL`.
Name string
// Events are the events it carries, as the bus spells them (WirePattern).
Events []string
MaxAgeDays int
MaxMiB int
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
Restricted bool
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
WorkQueue bool
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
// WorkQueue.
Duplicates int
}
// Subjects are the subjects the stream takes.
func (s ModuleStream) Subjects() []string {
var out []string
for _, e := range s.Events {
out = append(out, ModuleStreamSubject(s.Module, e))
}
sort.Strings(out)
return out
}
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
// answers for anything older.
func (s ModuleStream) Stream() Stream {
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
if s.Restricted {
why += "; heard only by the consumers its trusted config value grants"
}
if s.WorkQueue {
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
}
out := Stream{
Name: s.Name,
Subjects: s.Subjects(),
Retention: RetentionLimits,
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
Why: why,
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
Restricted: s.Restricted,
}
if s.WorkQueue {
out.Retention = RetentionWorkQueue
out.DiscardNew = true
out.DuplicatesSeconds = s.Duplicates * 60 * 60
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
out.Restricted = true
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
// streams share without being one of this clause's: only this flag says a dead letter kept from the
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
out.WorkQueue = true
}
return out
}
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
// to be the only one (the indexer's judge, novox/hq ADR 0578 §1).
type QueueOwner struct {
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
// 0436 §2).
Process string
// Stream is the work-queue stream's name on the bus.
Stream string
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
// (novox/hq ADR 0436 §6).
MaxDeliver int
}
// QueueConsumer is the durable consumer the one process a module's bus.queues names is granted on its own
// work-queue stream (novox/hq ADR 0436 §1, §2, §6): no filter, since it is the stream's only consumer —
// unlike heardConsumer, which is one of possibly several hearing a module's ordinary stream.
func QueueConsumer(p Principal, q QueueOwner) Consumer {
return Consumer{
Name: p.Node + "_" + p.Module + "_" + q.Process,
Stream: q.Stream,
AckWaitSeconds: 30,
MaxDeliver: q.MaxDeliver,
Why: fmt.Sprintf("%s's process %s is the one consumer %s's bus.queues names for its work-queue "+
"stream %s (novox/hq ADR 0436 §6); after max-deliver %d it gives a message up, and the "+
"controller keeps it in %s, offered only drop", p.Module, q.Process, p.Module, q.Stream,
q.MaxDeliver, DeadLettersStream),
}
}
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
func ModuleStreams(declared []ModuleStream) []Stream {
sorted := append([]ModuleStream(nil), declared...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
out := make([]Stream, 0, len(sorted))
for _, s := range sorted {
out = append(out, s.Stream())
}
return out
}
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
// (ADR 0363 §5). Composed only with at least one subject.
type Heard struct {
Stream string
Emitter string
Filters []string
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
Restricted bool
}
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
// the mesh keeps from agents.
func ComposedHears(d Declared) []Heard {
if d.RunsAs != "" {
return d.Hears
}
var out []Heard
for _, h := range d.Hears {
if !h.Restricted {
out = append(out, h)
}
}
return out
}
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
// silent.
type Withheld struct {
Node, Module, Stream, Emitter string
}
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
func (w Withheld) Said() string {
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
}
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
func WithheldOf(r Records) []Withheld {
var out []Withheld
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
if d.RunsAs != "" {
continue
}
for _, h := range d.Hears {
if h.Restricted && len(h.Filters) > 0 {
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
}
}
}
}
return out
}
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
// on EVENTS, on that stream, with the subjects it was granted as its filters.
func heardConsumer(p Principal, h Heard) Consumer {
filters := append([]string(nil), h.Filters...)
sort.Strings(filters)
return Consumer{
Name: consumerDurable(p),
Stream: h.Stream,
Filters: filters,
AckWaitSeconds: 30,
MaxDeliver: 5,
// From now, like a module's consumer on EVENTS (novox/hq ADR 0443 §1).
FromNow: true,
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
"DEAD_LETTERS until a person delivers it again or drops it",
}
}
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
func heardGrants(p Principal, hears []Heard) []string {
var out []string
durable := consumerDurable(p)
for _, h := range hears {
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
continue
}
out = append(out,
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
"$JS.ACK."+h.Stream+"."+durable+".>")
}
return out
}
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
func ownStreamGrants(module string, streams []ModuleStream) []string {
var out []string
for _, s := range streams {
if s.Module != module {
continue
}
out = append(out, s.Subjects()...)
}
return out
}
// streamsIssuedFor is every stream of its own a module's membership names.
func streamsIssuedFor(d Declared) []StreamIssued {
var out []StreamIssued
for _, s := range d.Streams {
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
continue
}
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
Subject: ModuleStreamSubject(d.Module, "{event}")})
}
return out
}
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
func HeardIssuedFor(node string, d Declared) []HeardIssued {
var out []HeardIssued
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
for _, h := range ComposedHears(d) {
if len(h.Filters) == 0 {
continue
}
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
}
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
return out
}
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
type Pruner interface {
ConsumerNames(stream string) ([]string, error)
RemoveConsumer(stream, name string) error
}
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
keep := map[string]bool{}
for _, c := range wanted {
keep[c.Stream+"/"+c.Name] = true
}
var removed []string
for _, s := range streams {
names, err := p.ConsumerNames(s.Name)
if err != nil {
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
}
for _, n := range names {
if keep[s.Name+"/"+n] {
continue
}
if err := p.RemoveConsumer(s.Name, n); err != nil {
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
}
removed = append(removed, s.Name+"/"+n)
}
}
return removed, nil
}
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
// returned either way, saying what was done, so the send says it and raises it.
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
err := r.EnsureConsumer(c)
if err == nil || !restricted {
return err
}
p, prunes := r.(Pruner)
if !prunes {
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
}
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
}
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
"again with its granted filters (novox/hq ADR 0363 §6)", err)
}
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,5 +0,0 @@
package buscompose
// NATSVar names the environment variable that says the controller is on the bus (mesh-controller internal/broker
// onnats.go); kept with the composer for the readiness it composes.
const NATSVar = "MESH_BUS_NATS"
-56
View File
@@ -1,56 +0,0 @@
package buscompose
// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5).
//
// **Never the module's own account's grants, never the tool runner's.** A process's authority is read
// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the
// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no
// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on
// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11).
// processGrants is one process's whole authority: to publish, and to subscribe.
func processGrants(p Principal) (pub, sub []string, err error) {
// 1. Its own events, under its module's own namespace — the same subjects the module's own principal
// would publish them on, had it declared them there instead (novox/hq ADR 0436 §1).
own := "mesh.mod." + p.Module
for _, e := range p.Emits {
pub = append(pub, own+".event."+e)
}
// 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6):
// asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under
// (QueueConsumer) — and no other stream's.
for _, q := range p.Queues {
consumer := p.Node + "_" + p.Module + "_" + q.Process
pub = append(pub,
"$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer,
"$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer,
"$JS.ACK."+q.Stream+"."+consumer+".>")
}
// 3. Its module's own state, by the local names its bus names alone — never the whole of what the
// module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket.
kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State}
pub = append(pub, stateGrants(kept)...)
// And it hears each of them created, so one not on the bus at its start is bound when it appears (novox/hq issue
// 1790; process.AwaitBucket).
sub = append(sub, stateCreatedHeard(kept)...)
// 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same
// direct-get subjects a module's own principal reads for the seats its Uses names that keep them —
// read here from the one list, so the two cannot disagree.
if p.Asks {
pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...)
}
// 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a
// module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal)
// adds the caller-named `call` variants for it exactly as it would for a module.
invoked, err := invokedSubjects(p.Invokes)
if err != nil {
return nil, nil, err
}
pub = append(pub, invoked...)
return pub, sub, nil
}
-229
View File
@@ -1,229 +0,0 @@
package buscompose
import (
"errors"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/provisions"
"github.com/nats-io/nats.go"
)
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3).
//
// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that
// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the
// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that
// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client
// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the
// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it
// applied on its own machine, and ask the vault in its own name.
// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3).
const NodeProvisionerSeat = "node-provisioner"
// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as
// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own
// secret of this name has nowhere to read an account, and is given none.
const ProvisionerSecret = "provisioner-broker"
// ConfigResolvedBucket is the config domain's resolved values, keyed `<node>.<module>.<key>` (ADR 0348, research
// 051): mesh-config's state `resolved`, written by mesh-config alone.
var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
// mesh-config's resolved values; and the seat mesh-provisioner's plans of its machine, `<node>.<module>` (novox/hq ADR
// 0570 clause 2). **Named one by one**, as the controller's asks are: a domain that moves to the
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
var ProvisionerReads = []string{ConfigResolvedBucket}
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the seat mesh-placement's placement,
// where a provider's consumers are placed on their own machines, which the provider machine's client reads to fill
// ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679; ADR 0567, issue 1126: the
// controller's assignments mirror it read before is gone). It holds placement, never a secret's value; the client
// writes none of it. **The seat's plans are read whole for the same
// reason** (2026-10-30 recovery, issue 774): the client V3 watches every machine's plans to bind a provider's
// consumers, and granted only its own machine's keys it read none. ADR 0570 clause 3 has providers act on the seat's
// provision keys instead; once the client does, plans go back to its own machine's keys.
var ProvisionerReadsWhole = []string{delivery.PlacementBucket, provisions.PlansBucket}
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
// ${bound:<provision>:<key>} (issue 679).
const BoundState = "bound"
// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3,
// design 57 P4), whose buckets the clients write their own machine's keys of.
const ProvisionerServerModule = "mesh-provisioner"
// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest
// like any module's, and written by its own account whole.
var (
// ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the
// server's alone, read whole by every client.
ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry")
// ProvisionerNeedsBucket is what each module on each machine needs, `<node>.<module>.<kind>.<name>`.
ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs")
// ProvisionerAppliedBucket is what is applied where, `<node>.<module>`.
ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied")
// ProvisionerCollectBucket is the items of every collection, `<node>.<collection>.<contributor>`.
ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect")
)
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A client
// cannot write another machine's needs, or say another machine applied a file.
//
// **The needs stay granted for one release, and are removed after the lease write** (review round 3 of design 47's
// release): the client's V3 reads its machine's plans from the seat mesh-provisioner's `plans` (novox/hq ADR 0570 clause
// 2) and records no needs, but the client before it still runs on a node until its delivery reaches it, and it writes
// them.
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject
// that names its caller, so the vault knows which machine's client asks from the bus's word.
var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}}
// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom,
// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A
// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4):
// it asks the vault again, in its own name, for a secret its machine's declaration names.
const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed
// with the reason (ADR 0349 clauses 4 and 15): `prov.applied.<node>.<module>`.
func ProvisionerAppliedSubject(node, module string) string {
return "prov.applied." + node + "." + module
}
// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value
// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR
// 0370 §1, §3, §4; issue 685): `prov.ask.<node>`, which the client subscribes and the tool runner's account of the
// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word,
// so the client answers no secret here (ADR 0370 §2).
func ProvisionerAskSubject(node string) string { return "prov.ask." + node }
// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the
// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed.<node>.<module>`,
// carrying which value and never the value itself, heard by that machine's tool runner alone.
func ProvisionerChangedSubject(node, module string) string {
return "prov.changed." + node + "." + module
}
// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe.
func provisionerGrants(p Principal) (pub, sub []string, err error) {
if p.Node == "" {
// Refused rather than composed into `$KV.<bucket>..>`, a pattern no machine's keys are under, and a
// name every nameless client would share.
return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " +
"to read and an account it shared with every other")
}
for _, bucket := range ProvisionerReads {
pub = append(pub, machineKeysRead(bucket, p.Node)...)
}
for _, bucket := range ProvisionerReadsWhole {
pub = append(pub, wholeBucketRead(bucket)...)
}
for _, bucket := range p.BoundBuckets {
module, name, ok := strings.Cut(bucket, "_")
if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") {
return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, <module>_%s", bucket, BoundState)
}
pub = append(pub, machineKeysRead(bucket, p.Node)...)
}
// The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the
// collections, read and written, `$KV.<bucket>.<node>.>` and nothing wider; and the registry, read whole,
// which says which domain answers which kind and is the same for every machine.
for _, bucket := range ProvisionerWrites {
pub = append(pub, machineKeysRead(bucket, p.Node)...)
pub = append(pub, "$KV."+bucket+"."+p.Node+".>")
}
pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...)
// Its own node's own have, `<node>.node`, read and never written, and no other key of the bucket: it fills every
// module again when the node-engine moves to a new composition of the node's own (issue 1945), since an apply can
// leave a filled file as the composition had it.
pub = append(pub, readGrants(delivery.HaveBucket, p.Node+".node")...)
pub = append(pub, "prov.applied."+p.Node+".>")
token := CallerToken(p.Username())
if token == "" {
return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username())
}
for _, v := range VerbsTheProvisionerAsksTheVault {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token)
}
// What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name.
pub = append(pub, "prov.changed."+p.Node+".>")
// Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says
// changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR
// 0370 §3), which it answers on the ask's reply and nowhere else.
return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil
}
// machineKeysRead is reading every key of a bucket under one machine's name, `<node>.>`, and no other key: a
// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter
// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260).
// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value.
func machineKeysRead(bucket, node string) []string {
stream := "KV_" + bucket
keys := "$KV." + bucket + "." + node + ".>"
return []string{
"$JS.API.STREAM.INFO." + stream,
"$JS.API.DIRECT.GET." + stream + "." + keys,
"$JS.API.CONSUMER.CREATE." + stream + ".*." + keys,
"$JS.API.CONSUMER.DELETE." + stream + ".>",
"$JS.FC." + stream + ".>",
}
}
// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a
// bucket it `reads`.
func wholeBucketRead(bucket string) []string {
stream := "KV_" + bucket
return []string{
"$JS.API.STREAM.INFO." + stream,
"$JS.API.DIRECT.GET." + stream + ".>",
"$JS.API.CONSUMER.CREATE." + stream + ".>",
"$JS.API.CONSUMER.DELETE." + stream + ".>",
"$JS.FC." + stream + ".>",
}
}
// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on
// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in
// the stream's week.
func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) {
msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module))
if errors.Is(err, nats.ErrMsgNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err)
}
return msg.Data, true, nil
}
// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by
// ProvAppliedStream, by subject (`prov.applied.<node>.<module>`).
func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) {
info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects})
if err != nil {
return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err)
}
out := map[string][]byte{}
for subject := range info.State.Subjects {
msg, err := js.GetLastMsg(ProvAppliedStream, subject)
if errors.Is(err, nats.ErrMsgNotFound) {
continue // aged out between the two reads
}
if err != nil {
return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err)
}
out[subject] = msg.Data
}
return out, nil
}
-73
View File
@@ -1,73 +0,0 @@
package buscompose
import "fmt"
// Bringing the bus's own objects into being, in the one order that works.
//
// **Asserted on every start rather than created once at genesis.** A stream somebody deleted, a mesh
// raised from a restored backup, or a bus whose data directory was replaced all have records and no
// objects — and a node whose consumer is missing hears nothing while everything else about it looks
// correct. Idempotence is the whole requirement, and the parts are already idempotent; this is the
// order they have to be asked in.
// Raiser is everything asserting the bus's objects needs of a connection to it.
type Raiser interface {
Asserter
Ensurer
}
// Raise asserts the mesh's streams, its own consumers, and one consumer per node.
//
// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the
// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted
// stream instead of a reversed pair of lines. Nodes last, because the one a node reads lives on a
// stream the mesh's own set defines.
func Raise(r Raiser, nodes []string) error {
if err := AssertMeshStreams(r); err != nil {
return err
}
if err := AssertMeshConsumers(r); err != nil {
return err
}
if err := AssertNodeConsumers(r, nodes); err != nil {
return err
}
return nil
}
// RaiseSeats asserts one work queue per declared seat, and the worker of whoever holds it.
//
// Separate from Raise because it is answered by a different question: the mesh's own objects exist
// because the mesh does, and a seat's exist because a module declaring one was registered. Kept
// beside it so the order is visible — a holder's worker needs the seat's stream, and a seat's stream
// needs nothing.
func RaiseSeats(r Raiser, seats []DeclaredSeat, holders map[string]Holder) error {
for _, s := range SeatStreams(seats) {
if err := r.EnsureStream(s); err != nil {
return fmt.Errorf("asserting the work queue for %s: %w", s.Name, err)
}
}
for _, s := range seats {
h, held := holders[s.Name]
if !held {
// **The stream exists and the consumer does not, on purpose.** Work queues until a
// holder appears, so installing the module a week after something started sending to it
// flushes the backlog instead of having lost it.
continue
}
c, needed := HolderConsumerFor(h.Node, h.Module, s)
if !needed {
continue
}
if err := r.EnsureConsumer(c); err != nil {
return fmt.Errorf("asserting how %s on %s works %s: %w", h.Module, h.Node, s.Name, err)
}
}
return nil
}
// Holder is which module on which machine holds a seat.
type Holder struct {
Node string
Module string
}

Some files were not shown because too many files have changed in this diff Show More