Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c55e5d44d | ||
|
|
32b912cfe5 | ||
|
|
e572937d97 | ||
|
|
567736f779 | ||
|
|
e901769cb2 | ||
|
|
8f9c78bcba | ||
|
|
82e7e3e326 | ||
|
|
363a3d3f7e | ||
|
|
609013a263 | ||
|
|
32ad59fc64 | ||
|
|
93f655dac9 | ||
|
|
573a59e3cc | ||
|
|
d0566f9ade | ||
|
|
9d9dfda19b | ||
|
|
cdbe2945cf | ||
|
|
4e82a824be | ||
|
|
78d26867d2 | ||
|
|
0c216d99d0 | ||
|
|
145c54d19c | ||
|
|
67092161bd | ||
|
|
2df0503034 | ||
|
|
23c827e791 | ||
|
|
3138570c6b | ||
|
|
a85d903741 | ||
|
|
1cc54959af | ||
|
|
962bc359a7 | ||
|
|
67b13c42d3 | ||
|
|
4021724ca4 | ||
|
|
f27f4c8cd5 | ||
|
|
155124b59a | ||
|
|
6ec6fdaad2 | ||
|
|
663f3de975 |
@@ -15,6 +15,19 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
|
||||
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
|
||||
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
|
||||
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
|
||||
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const file = env.MESH_GITEA_CONFIG_FILE;
|
||||
if (!file) return true;
|
||||
try {
|
||||
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
|
||||
return !(v === false || v === "false");
|
||||
} catch { return true; }
|
||||
}
|
||||
|
||||
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
|
||||
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
|
||||
let said = false;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
|
||||
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
|
||||
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
|
||||
import { checkGatesMerge } from "./mainrules.js";
|
||||
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
|
||||
|
||||
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
|
||||
@@ -31,11 +32,13 @@ export interface MergeDeps {
|
||||
emit?: (type: string, body: unknown) => Promise<void>;
|
||||
stateDir?: string;
|
||||
now?: () => Date;
|
||||
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
|
||||
checkGates?: () => boolean;
|
||||
}
|
||||
|
||||
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
|
||||
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
|
||||
now: given.now ?? (() => new Date()) };
|
||||
now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
|
||||
return {
|
||||
name: "gitea_merge_pull_request",
|
||||
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
|
||||
@@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
|
||||
}
|
||||
const kept = maintained(required);
|
||||
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
|
||||
if (byCheck) {
|
||||
// **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
|
||||
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
|
||||
// approved_heads the check still judges.
|
||||
if (byCheck && (deps.checkGates() || !approved)) {
|
||||
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
|
||||
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
|
||||
const check = checks[CHECK_STATUS];
|
||||
|
||||
@@ -181,6 +181,12 @@
|
||||
"trusted": true,
|
||||
"default": {}
|
||||
},
|
||||
"check-gates-merge": {
|
||||
"type": "boolean",
|
||||
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
|
||||
"trusted": true,
|
||||
"default": true
|
||||
},
|
||||
"main-mergers": {
|
||||
"type": "list",
|
||||
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
|
||||
@@ -316,7 +322,8 @@
|
||||
"takes-config": [
|
||||
"url",
|
||||
"keep-main-rules",
|
||||
"main-mergers"
|
||||
"main-mergers",
|
||||
"check-gates-merge"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -525,6 +525,18 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status,
|
||||
|
||||
state.statuses = line(TRUNK);
|
||||
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
|
||||
|
||||
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
|
||||
// without approved_heads it is refused as before.
|
||||
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
|
||||
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
|
||||
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
|
||||
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
|
||||
state.merged = false;
|
||||
state.statuses = statuses;
|
||||
await assert.rejects(mergeUngated({}), /Approve/);
|
||||
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
|
||||
}
|
||||
});
|
||||
|
||||
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
|
||||
|
||||
@@ -508,11 +508,11 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
|
||||
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
|
||||
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
|
||||
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string } | undefined;
|
||||
if (number > 0) {
|
||||
const p = await gitea.getPullRequest(owner, repo, number);
|
||||
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
|
||||
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url };
|
||||
if (!ref) ref = p.head_sha ?? "";
|
||||
if (!base) base = p.base ?? "";
|
||||
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
|
||||
|
||||
@@ -14,6 +14,7 @@ var Kept = []announce.State{
|
||||
{Name: "toolchains", Readable: true},
|
||||
{Name: "keys", Readable: true},
|
||||
{Name: "steps", Readable: true},
|
||||
{Name: "pauses", Readable: true},
|
||||
}
|
||||
|
||||
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
|
||||
|
||||
@@ -80,19 +80,23 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
source = buildstep.SeatAsks{Language: delivery.AskGo}
|
||||
}
|
||||
capacity := m.Capacity
|
||||
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
|
||||
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
if capacity == nil {
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
watch = buildstep.WatchConfigServed
|
||||
}
|
||||
ask := m.askSteps(steps)
|
||||
var load func(string) ([]byte, error)
|
||||
if m.ReadSteps.Get != nil {
|
||||
if m.ReadPauses.Get != nil {
|
||||
load = func(key string) ([]byte, error) {
|
||||
raw, err := m.ReadSteps.Get(key)
|
||||
raw, err := m.ReadPauses.Get(key)
|
||||
return raw, err
|
||||
}
|
||||
}
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, Load: load,
|
||||
Capacity: capacity, Logf: m.Say,
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
|
||||
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -52,6 +53,10 @@ type TestTime struct {
|
||||
// Failure is a test that failed, or a package that failed with no test to name (a build that failed, a TestMain that
|
||||
// panicked); Output is the last lines it wrote, up to outputBytes (issue 1901), and Cause the first line of what the
|
||||
// test reported as its error (t.Error, t.Fatal), without its file and line, when go test said which lines those were.
|
||||
// **A failure that is not one test's assertion carries its real cause too** (issue 1901, its third meeting): the
|
||||
// "panic:" line (a timeout's with the tests it found running), a "fatal error:" or "signal:" line, a build's first
|
||||
// error, or in words that the test program exited, while which tests ran or after its tests passed. Test then names the
|
||||
// tests that were running when the program stopped. A "PASS" or "ok" line is never a failure's words.
|
||||
type Failure struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test,omitempty"`
|
||||
@@ -126,11 +131,18 @@ type reader struct {
|
||||
causes map[string]string // the first error line of a test, by the same key
|
||||
times []TestTime
|
||||
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 (issue 1901).
|
||||
running map[string][]string
|
||||
// passed says a package's test program printed "PASS" (its tests passed) before it failed; built says its test
|
||||
// program failed to build.
|
||||
passed, built map[string]bool
|
||||
}
|
||||
|
||||
func newReader(res *TestResult, dir string) *reader {
|
||||
return &reader{res: res, dir: dir, pkgs: map[string]*PackageResult{}, builds: map[string][]string{},
|
||||
pkgOut: map[string][]string{}, testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{}}
|
||||
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{}}
|
||||
}
|
||||
|
||||
// read takes go test's output. Lines that are not events are kept for a failure that has nothing else to say.
|
||||
@@ -178,12 +190,21 @@ func (r *reader) event(ev event) {
|
||||
p := r.pkg(ev.Package)
|
||||
switch ev.Action {
|
||||
case "output":
|
||||
r.pkgOut[ev.Package] = keep(r.pkgOut[ev.Package], ev.Output)
|
||||
// go test's own summary of a package ("PASS", "FAIL", "ok <pkg>", "FAIL <pkg>") 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 summaryLine(l):
|
||||
default:
|
||||
r.pkgOut[ev.Package] = keep(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":
|
||||
@@ -197,7 +218,10 @@ func (r *reader) event(ev event) {
|
||||
if ev.OutputType == "error" && r.causes[key] == "" {
|
||||
r.causes[key] = errorWords(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++
|
||||
@@ -207,16 +231,23 @@ func (r *reader) event(ev event) {
|
||||
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
|
||||
if ev.Action == "fail" {
|
||||
r.failedIn[ev.Package] = true
|
||||
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key], r.causes[key]), Cause: r.causes[key]})
|
||||
cause := r.causes[key]
|
||||
if race := raceCause(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 = causeOf(r.testOut[key])
|
||||
}
|
||||
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key], cause), Cause: cause})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clip joins the last lines of a failure's output that fit outputBytes, each cut to outputLineBytes, and says how many
|
||||
// earlier lines it left out. **When it leaves lines out, its first line is still a real one** (issue 1901/AC3): the failure's
|
||||
// cause, or failing that the first non-empty line of the output, then the marker. An older checker takes only the first
|
||||
// non-empty line of the output, and a marker there would be less than the older pair kept.
|
||||
// earlier lines it left out. **Its first line is the failure's cause** (issue 1901/AC3, AC4), put ahead when the kept
|
||||
// lines do not start with it, or, with no cause and lines left out, the first non-empty line of the output, then the
|
||||
// marker. An older checker takes only the first non-empty line of the output, and a marker there would be less than the
|
||||
// older pair kept.
|
||||
func clip(lines []string, cause string) string {
|
||||
size, from := 0, len(lines)
|
||||
for from > 0 {
|
||||
@@ -227,15 +258,15 @@ func clip(lines []string, cause string) string {
|
||||
size += n
|
||||
from--
|
||||
}
|
||||
kept := make([]string, 0, len(lines)-from+1)
|
||||
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 {
|
||||
head := strings.TrimSpace(cause)
|
||||
for i := 0; head == "" && i < from; i++ {
|
||||
head = strings.TrimSpace(lines[i])
|
||||
}
|
||||
if head != "" {
|
||||
kept = append(kept, cutLine(head))
|
||||
}
|
||||
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
|
||||
}
|
||||
for _, l := range lines[from:] {
|
||||
@@ -244,6 +275,159 @@ func clip(lines []string, cause string) string {
|
||||
return strings.Join(kept, "\n")
|
||||
}
|
||||
|
||||
// summaryLine 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>" (review round 1: a line of the package's own that starts "ok " is kept).
|
||||
func summaryLine(l string) bool {
|
||||
return l == "PASS" || l == "FAIL" || strings.HasPrefix(l, "ok \t") || strings.HasPrefix(l, "FAIL\t")
|
||||
}
|
||||
|
||||
// causeOf is the cause in a failure's output that is not a test's assertion (issue 1901): a race the race detector
|
||||
// reported (raceCause); else the first "panic:" line, a timeout's with the tests it names as running; else the first
|
||||
// "fatal error:" (the runtime's) or "signal:" line (a test program killed); "" when there is none. It reads every line,
|
||||
// so a long goroutine dump after the panic 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 (review round 1).
|
||||
func causeOf(lines []string) string {
|
||||
if race := raceCause(lines); race != "" {
|
||||
return race
|
||||
}
|
||||
for _, prefix := range []string{"panic: ", "fatal error: ", "signal: "} {
|
||||
for i, l := range lines {
|
||||
l = strings.TrimRight(l, "\r\n") // at column 0: an indented line is a test's own log, not the runtime's
|
||||
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 ""
|
||||
}
|
||||
|
||||
// raceCause is the first race the race detector reported in a failure's output, in one line (issue 1901: a race after
|
||||
// the tests' "PASS" was worded "PASS"): its access and the frame that made it, and where the goroutine that made it was
|
||||
// created, as "WARNING: DATA RACE: Write at 0x... by goroutine 10 in pkg.F.func1() (f_test.go:13); goroutine created at
|
||||
// pkg.F() (f_test.go:13)". "" when the output holds no race.
|
||||
func raceCause(lines []string) string {
|
||||
at := -1
|
||||
for i, l := range lines {
|
||||
if strings.TrimRight(l, "\r\n") == "WARNING: DATA RACE" { // at column 0, as causeOf
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
return ""
|
||||
}
|
||||
// frame is the function and the file's base name and line that follow a heading line at i.
|
||||
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)
|
||||
}
|
||||
|
||||
// buildCause is the first error line of a failed build: the first line that is not go's "# <package>" heading.
|
||||
func buildCause(lines []string) string {
|
||||
for _, l := range lines {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "# ") {
|
||||
return cutLine(l)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// testNames names the tests that were running, at most three, and how many more.
|
||||
func testNames(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 (issue 1901): its test program did not build,
|
||||
// panicked, timed out, was killed or exited. It names the tests that were running when the program stopped, keeps
|
||||
// their output with the package's, and says the cause.
|
||||
func (r *reader) 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 := causeOf(lines)
|
||||
switch {
|
||||
case cause != "":
|
||||
case r.built[name]:
|
||||
cause = buildCause(lines)
|
||||
case len(running) > 0:
|
||||
cause = "the test program exited while " + testNames(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: testNames(running), Output: clip(lines, cause), Cause: cause}
|
||||
}
|
||||
|
||||
// cutLine cuts a line to outputLineBytes, on a character boundary.
|
||||
func cutLine(l string) string {
|
||||
if len(l) <= outputLineBytes {
|
||||
@@ -265,7 +449,7 @@ func (r *reader) finish() {
|
||||
for _, name := range r.order {
|
||||
p := r.pkgs[name]
|
||||
if !p.Passed && !r.failedIn[name] {
|
||||
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name], "")})
|
||||
r.res.Failed = append(r.res.Failed, r.packageFailure(name))
|
||||
}
|
||||
if !p.NoTests && p.Elapsed > r.res.Elapsed {
|
||||
r.res.Elapsed = p.Elapsed
|
||||
|
||||
@@ -40,6 +40,8 @@ func main() {
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
Pauses: stdio.State(delivery.PausesState).Put,
|
||||
ReadPauses: readOf(stdio.State(delivery.PausesState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
|
||||
@@ -92,9 +92,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
|
||||
states := []string{}
|
||||
for _, s := range m.State {
|
||||
states = append(states, s.Name)
|
||||
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
|
||||
if (s.Name == "steps") != (s.TTLSeconds > 0) {
|
||||
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
|
||||
}
|
||||
}
|
||||
slices.Sort(states)
|
||||
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat {
|
||||
|
||||
@@ -29,6 +29,9 @@ type Module struct {
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
||||
Pauses buildstep.Put
|
||||
ReadPauses StateRead
|
||||
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
|
||||
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
|
||||
Emit func(eventType string, body any) error
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A failure that is not one test's assertion carries its real cause (issue 1901, its third meeting: a try of
|
||||
// mesh-controller kept only "PASS" for a package that stopped half-way). Each fixture is the real `go test -json` of a
|
||||
// throwaway package (testdata/captured/README).
|
||||
func TestAFailureThatIsNotAnAssertionCarriesItsRealCause(t *testing.T) { // 1901/AC4
|
||||
cases := []struct {
|
||||
fixture string
|
||||
test string // the test the failure names; "" for none
|
||||
cause []string // what the cause holds
|
||||
}{
|
||||
{"panicking", "TestPanics", []string{"panic: assignment to entry in nil map"}},
|
||||
{"timeout", "TestWaitsForever", []string{"panic: test timed out after 2s", "TestWaitsForever"}},
|
||||
{"deeptimeout", "TestHoldsTheSeat, TestWaitsForTheBus", []string{"panic: test timed out after 2s", "TestHoldsTheSeat", "TestWaitsForTheBus"}},
|
||||
{"buildfail", "", []string{`cannot use "seat" (untyped string constant) as int value`}},
|
||||
{"exiting", "TestExits", []string{"exited", "TestExits"}},
|
||||
{"killed", "TestKilled", []string{"signal: killed"}},
|
||||
{"passthenfail", "", []string{"panic: the bus closed after the tests: nats: connection closed"}},
|
||||
{"mainexit", "", []string{"passed", "exited"}},
|
||||
{"race", "TestRacesOnTheSeat", []string{"DATA RACE", "Write at", "example.com/race.TestRacesOnTheSeat.func1()", "p_test.go:13", "created at example.com/race.TestRacesOnTheSeat()"}},
|
||||
{"racemain", "", []string{"DATA RACE", "Write at", "example.com/racemain.TestMain.func1()", "p_test.go:15", "created at example.com/racemain.TestMain()"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.fixture, func(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/captured/go-test-json-pkg-" + c.fixture + ".jsonl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := &TestResult{}
|
||||
rd := newReader(res, ".")
|
||||
rd.read(string(raw))
|
||||
rd.finish()
|
||||
if len(res.Failed) != 1 {
|
||||
t.Fatalf("failures = %+v, want one", res.Failed)
|
||||
}
|
||||
f := res.Failed[0]
|
||||
if f.Test != c.test {
|
||||
t.Errorf("1901/AC4: the failure names test %q, want %q", f.Test, c.test)
|
||||
}
|
||||
for _, want := range c.cause {
|
||||
if !strings.Contains(f.Cause, want) {
|
||||
t.Errorf("1901/AC4: cause = %q, want it to hold %q", f.Cause, want)
|
||||
}
|
||||
}
|
||||
if len(f.Cause) > outputLineBytes+3 {
|
||||
t.Errorf("1901/AC4: cause holds %d bytes, over the line bound %d", len(f.Cause), outputLineBytes)
|
||||
}
|
||||
// Never a "PASS" or "ok" line as a failure's words, and the first line of the output, all an older
|
||||
// checker reads, is the cause.
|
||||
for _, l := range strings.Split(f.Output, "\n") {
|
||||
if l = strings.TrimSpace(l); l == "PASS" || strings.HasPrefix(l, "ok ") || strings.HasPrefix(l, "ok\t") {
|
||||
t.Errorf("1901/AC4: the output keeps the line %q: %q", l, f.Output)
|
||||
}
|
||||
}
|
||||
if first := strings.SplitN(f.Output, "\n", 2)[0]; strings.TrimSpace(first) != strings.TrimSpace(f.Cause) {
|
||||
t.Errorf("1901/AC4: the output starts with %q, want the cause %q", first, f.Cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A package that passes, and a test that fails on its own assertion, keep what they had: no cause is made up for them.
|
||||
func TestAnAssertionKeepsItsOwnCause(t *testing.T) { // 1901/AC4
|
||||
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := &TestResult{}
|
||||
rd := newReader(res, ".")
|
||||
rd.read(string(raw))
|
||||
rd.finish()
|
||||
if len(res.Failed) != 1 || !strings.HasSuffix(res.Failed[0].Cause, "3 subscription(s) derive to a subject no emitter owns:") {
|
||||
t.Errorf("1901/AC4: failures = %+v", res.Failed)
|
||||
}
|
||||
}
|
||||
|
||||
// go prints the runtime's lines ("panic:", "fatal error:", "signal:", "WARNING: DATA RACE") at column 0: a test's own
|
||||
// log line that holds the same words is indented, and is not the cause (review round 1). The fixture is the deeptimeout
|
||||
// capture with two log lines of the first running test added: a t.Logf and its indented continuation saying "panic:".
|
||||
func TestALoggedPanicIsNotTheCause(t *testing.T) { // 1901/AC4
|
||||
raw, err := os.ReadFile("testdata/captured/go-test-json-pkg-deeptimeout.jsonl")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.SplitAfter(string(raw), "\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"
|
||||
out := strings.Join(lines[:10], "") + added + strings.Join(lines[10:], "")
|
||||
res := &TestResult{}
|
||||
rd := newReader(res, ".")
|
||||
rd.read(out)
|
||||
rd.finish()
|
||||
if len(res.Failed) != 1 || !strings.HasPrefix(res.Failed[0].Cause, "panic: test timed out after 2s") {
|
||||
t.Errorf("1901/AC4: failures = %+v, want the timeout's panic as the cause", res.Failed)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -39,6 +42,53 @@ func readOf(s stdio.KeptState) StateRead {
|
||||
}}
|
||||
}
|
||||
|
||||
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
|
||||
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
|
||||
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
|
||||
const (
|
||||
MaxStateReads = 1000
|
||||
stateReadParallel = 16
|
||||
)
|
||||
|
||||
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
|
||||
// stops starting reads after the first error and returns it.
|
||||
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
|
||||
out := make([]json.RawMessage, len(keys))
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fail error
|
||||
next = make(chan int)
|
||||
)
|
||||
for w := 0; w < stateReadParallel && w < len(keys); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
raw, err := get(keys[i])
|
||||
mu.Lock()
|
||||
if err != nil && fail == nil {
|
||||
fail = err
|
||||
}
|
||||
mu.Unlock()
|
||||
out[i] = raw
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range keys {
|
||||
mu.Lock()
|
||||
stop := fail != nil
|
||||
mu.Unlock()
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
wg.Wait()
|
||||
return out, fail
|
||||
}
|
||||
|
||||
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
|
||||
func (m *Module) stepKey(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
@@ -80,27 +130,41 @@ type StepLine struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
|
||||
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
|
||||
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
|
||||
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
|
||||
// read. The values still order the lines, since an ask step's id carries the ask time.
|
||||
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
|
||||
return nil, fmt.Errorf("the steps state cannot be read here")
|
||||
return nil, false, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
start := time.Now()
|
||||
keys, err := m.ReadSteps.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
own := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, m.Node+".") {
|
||||
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
|
||||
own = append(own, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(own)))
|
||||
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
|
||||
own, cut = own[:n], true
|
||||
}
|
||||
raws, err := readValues(own, m.ReadSteps.Get)
|
||||
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
|
||||
if err != nil {
|
||||
return nil, cut, err
|
||||
}
|
||||
var steps []delivery.Step
|
||||
for _, k := range keys {
|
||||
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
|
||||
continue
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range raws {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -122,7 +186,7 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
|
||||
@@ -130,24 +194,30 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
|
||||
return nil, fmt.Errorf("the toolchains state cannot be read here")
|
||||
}
|
||||
start := time.Now()
|
||||
keys, err := m.ReadToolchainsState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cut := len(keys) > MaxStateReads
|
||||
if cut {
|
||||
keys = keys[:MaxStateReads] // a node has a key per language: far fewer in practice
|
||||
}
|
||||
raws, err := readValues(keys, m.ReadToolchainsState.Get)
|
||||
log.Printf("toolchains read: %d keys read in %v (cut %v: keys past %d were not read)", len(keys), time.Since(start).Round(time.Millisecond), cut, MaxStateReads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]delivery.Toolchain{}
|
||||
for _, k := range keys {
|
||||
raw, err := m.ReadToolchainsState.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, raw := range raws {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
t, err := delivery.DecodeToolchain(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
return nil, fmt.Errorf("%s: %w", keys[i], err)
|
||||
}
|
||||
out[k] = t
|
||||
out[keys[i]] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -171,8 +241,13 @@ func (m *Module) ReadTools() []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
steps, err := m.RecentSteps(intArg(args["limit"]))
|
||||
return map[string]any{"node": m.Node, "steps": steps}, err
|
||||
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
|
||||
ans := map[string]any{"node": m.Node, "steps": steps}
|
||||
if cut {
|
||||
ans["cut"] = true
|
||||
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
|
||||
}
|
||||
return ans, err
|
||||
}},
|
||||
{Name: ToolchainsReadTool,
|
||||
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -145,3 +147,62 @@ func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
|
||||
t.Errorf("the read refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
|
||||
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
|
||||
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
|
||||
for _, row := range []struct {
|
||||
perNode, limit int
|
||||
reads int64
|
||||
steps int
|
||||
cut bool
|
||||
}{
|
||||
{20, 0, 20, 10, false},
|
||||
{600, 0, 100, 10, true},
|
||||
{1500, 300, MaxStateReads, 300, true},
|
||||
} {
|
||||
var reads, inFlight, maxFlight atomic.Int64
|
||||
stored := map[string][]byte{}
|
||||
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
||||
for i := 0; i < row.perNode; i++ {
|
||||
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
|
||||
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
|
||||
}
|
||||
}
|
||||
m := &Module{Node: "shanks"}
|
||||
m.ReadSteps = StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
var k []string
|
||||
for key := range stored {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) {
|
||||
reads.Add(1)
|
||||
now := inFlight.Add(1)
|
||||
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
inFlight.Add(-1)
|
||||
if !strings.HasPrefix(key, "shanks.") {
|
||||
t.Errorf("read %s, a key of another node", key)
|
||||
}
|
||||
return stored[key], nil
|
||||
},
|
||||
}
|
||||
start := time.Now()
|
||||
lines, cut, err := m.RecentSteps(row.limit)
|
||||
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
|
||||
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
|
||||
}
|
||||
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
|
||||
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
|
||||
}
|
||||
if maxFlight.Load() < 2 {
|
||||
t.Errorf("%d a node: values read one at a time", row.perNode)
|
||||
}
|
||||
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,15 @@ The tar streams (the archives gunzipped) of the three archives above, captured t
|
||||
gunzip and sha256) at origin/main 0205b8f7 on 2026-10-21: directory f8af1d94...d1b6, within 294e2bec...07fb, file
|
||||
055cffc2...428e. The digests above hold the gzip bytes of go1.27.1. The first try of issue 1589 on the merge check's build node answered
|
||||
other digests; the tests therefore compare the stream always and the digest only under go1.27.1.
|
||||
|
||||
go-test-json-pkg-*.jsonl are the standard output and error of `go test -json -count=1 ./...` (go1.27.3, the timeouts with
|
||||
`-timeout 2s`), captured on 2026-10-25 for issue 1901 in throwaway one-file modules that each fail 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) and mainexit (TestMain exits 2 after m.Run passed: go test
|
||||
prints "PASS" and then "FAIL <pkg>").
|
||||
go-test-json-pkg-race.jsonl and go-test-json-pkg-racemain.jsonl are the same, with `go test -race -json -count=1 ./...`
|
||||
(CGO_ENABLED=1, go1.27.3, 2026-10-25, issue 1901): race (TestRacesOnTheSeat writes a package variable from itself and a
|
||||
goroutine) and racemain (TestMain does so after m.Run passed: go test prints "PASS", then the race report and "Found 1
|
||||
data race(s)", then "FAIL <pkg>").
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{"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]"}
|
||||
Vendored
+417
@@ -0,0 +1,417 @@
|
||||
{"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}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{"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}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{"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}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{"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}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{"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}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
{"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}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
{"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}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{"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}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{"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}
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980 h1:ZEEeYibEPD1rngOAqbODzRY5rUStGC46OpyQwtNCYbs=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024125710-bfc3883af980/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8 h1:x4mvY7yiGTP5lnzxbiwyj1o24p05hAmzZP7xHg3XX2M=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"state": [
|
||||
"toolchains",
|
||||
"keys",
|
||||
"steps"
|
||||
{"name": "steps", "ttl-seconds": 172800}, "pauses"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var Kept = []announce.State{
|
||||
{Name: "keys", Readable: true},
|
||||
{Name: "steps", Readable: true},
|
||||
{Name: "pauses", Readable: true},
|
||||
}
|
||||
|
||||
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
|
||||
|
||||
@@ -37,16 +37,20 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
source = buildstep.SeatAsks{Language: delivery.AskImage}
|
||||
}
|
||||
capacity := m.Capacity
|
||||
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
|
||||
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
if capacity == nil {
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
watch = buildstep.WatchConfigServed
|
||||
}
|
||||
ask := m.askSteps(steps)
|
||||
var load func(string) ([]byte, error)
|
||||
if m.ReadSteps.Get != nil {
|
||||
load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) }
|
||||
if m.ReadPauses.Get != nil {
|
||||
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
|
||||
}
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, Load: load,
|
||||
Capacity: capacity, Logf: m.Say,
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
|
||||
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -29,13 +29,15 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image]
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
m := &Module{
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Work: WorkDir,
|
||||
Store: StoreDir,
|
||||
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
Pauses: stdio.State(delivery.PausesState).Put,
|
||||
ReadPauses: readOf(stdio.State(delivery.PausesState)),
|
||||
Ctx: ctx,
|
||||
Say: logf,
|
||||
Work: WorkDir,
|
||||
Store: StoreDir,
|
||||
}
|
||||
m.Floor = floorOf(m.Node)
|
||||
if u, err := user.Current(); err == nil {
|
||||
|
||||
@@ -97,9 +97,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
|
||||
states := []string{}
|
||||
for _, s := range m.State {
|
||||
states = append(states, s.Name)
|
||||
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
|
||||
if (s.Name == "steps") != (s.TTLSeconds > 0) {
|
||||
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
|
||||
}
|
||||
}
|
||||
slices.Sort(states)
|
||||
if want := []string{"keys", "steps"}; !slices.Equal(states, want) {
|
||||
if want := []string{"keys", "pauses", "steps"}; !slices.Equal(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
|
||||
|
||||
@@ -24,6 +24,9 @@ type Module struct {
|
||||
// Steps is the Put of the steps state; ReadSteps reads it back for the read tools.
|
||||
Steps buildstep.Put
|
||||
ReadSteps StateRead
|
||||
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
||||
Pauses buildstep.Put
|
||||
ReadPauses StateRead
|
||||
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
|
||||
Ctx context.Context
|
||||
Say func(format string, a ...any)
|
||||
|
||||
@@ -7,9 +7,12 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -38,6 +41,53 @@ func readOf(s stdio.KeptState) StateRead {
|
||||
}}
|
||||
}
|
||||
|
||||
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
|
||||
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
|
||||
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
|
||||
const (
|
||||
MaxStateReads = 1000
|
||||
stateReadParallel = 16
|
||||
)
|
||||
|
||||
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
|
||||
// stops starting reads after the first error and returns it.
|
||||
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
|
||||
out := make([]json.RawMessage, len(keys))
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fail error
|
||||
next = make(chan int)
|
||||
)
|
||||
for w := 0; w < stateReadParallel && w < len(keys); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
raw, err := get(keys[i])
|
||||
mu.Lock()
|
||||
if err != nil && fail == nil {
|
||||
fail = err
|
||||
}
|
||||
mu.Unlock()
|
||||
out[i] = raw
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range keys {
|
||||
mu.Lock()
|
||||
stop := fail != nil
|
||||
mu.Unlock()
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
wg.Wait()
|
||||
return out, fail
|
||||
}
|
||||
|
||||
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
|
||||
func (m *Module) stepKey(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
@@ -79,27 +129,41 @@ type StepLine struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
|
||||
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
|
||||
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
|
||||
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
|
||||
// read. The values still order the lines, since an ask step's id carries the ask time.
|
||||
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
|
||||
return nil, fmt.Errorf("the steps state cannot be read here")
|
||||
return nil, false, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
start := time.Now()
|
||||
keys, err := m.ReadSteps.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
own := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, m.Node+".") {
|
||||
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
|
||||
own = append(own, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(own)))
|
||||
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
|
||||
own, cut = own[:n], true
|
||||
}
|
||||
raws, err := readValues(own, m.ReadSteps.Get)
|
||||
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
|
||||
if err != nil {
|
||||
return nil, cut, err
|
||||
}
|
||||
var steps []delivery.Step
|
||||
for _, k := range keys {
|
||||
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
|
||||
continue
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range raws {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -121,7 +185,7 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
@@ -143,8 +207,13 @@ func (m *Module) ReadTools() []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
steps, err := m.RecentSteps(intArg(args["limit"]))
|
||||
return map[string]any{"node": m.Node, "steps": steps}, err
|
||||
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
|
||||
ans := map[string]any{"node": m.Node, "steps": steps}
|
||||
if cut {
|
||||
ans["cut"] = true
|
||||
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
|
||||
}
|
||||
return ans, err
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -39,8 +43,67 @@ func TestTheReadToolsAnswerAStepsResult(t *testing.T) {
|
||||
if err := json.Unmarshal(got.Result, &back); err != nil || back.Digest != res.Digest {
|
||||
t.Errorf("result = %s, %v", got.Result, err)
|
||||
}
|
||||
lines, err := m.RecentSteps(0)
|
||||
lines, _, err := m.RecentSteps(0)
|
||||
if err != nil || len(lines) != 1 || lines[0].Tool != BuildTool {
|
||||
t.Errorf("steps = %+v, %v", lines, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
|
||||
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
|
||||
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
|
||||
for _, row := range []struct {
|
||||
perNode, limit int
|
||||
reads int64
|
||||
steps int
|
||||
cut bool
|
||||
}{
|
||||
{20, 0, 20, 10, false},
|
||||
{600, 0, 100, 10, true},
|
||||
{1500, 300, MaxStateReads, 300, true},
|
||||
} {
|
||||
var reads, inFlight, maxFlight atomic.Int64
|
||||
stored := map[string][]byte{}
|
||||
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
||||
for i := 0; i < row.perNode; i++ {
|
||||
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
|
||||
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
|
||||
}
|
||||
}
|
||||
m := &Module{Node: "shanks"}
|
||||
m.ReadSteps = StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
var k []string
|
||||
for key := range stored {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) {
|
||||
reads.Add(1)
|
||||
now := inFlight.Add(1)
|
||||
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
inFlight.Add(-1)
|
||||
if !strings.HasPrefix(key, "shanks.") {
|
||||
t.Errorf("read %s, a key of another node", key)
|
||||
}
|
||||
return stored[key], nil
|
||||
},
|
||||
}
|
||||
start := time.Now()
|
||||
lines, cut, err := m.RecentSteps(row.limit)
|
||||
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
|
||||
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
|
||||
}
|
||||
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
|
||||
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
|
||||
}
|
||||
if maxFlight.Load() < 2 {
|
||||
t.Errorf("%d a node: values read one at a time", row.perNode)
|
||||
}
|
||||
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8 h1:x4mvY7yiGTP5lnzxbiwyj1o24p05hAmzZP7xHg3XX2M=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
],
|
||||
"state": [
|
||||
"keys",
|
||||
"steps"
|
||||
{"name": "steps", "ttl-seconds": 172800}, "pauses"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ var Kept = []announce.State{
|
||||
{Name: "toolchains", Readable: true},
|
||||
{Name: "keys", Readable: true},
|
||||
{Name: "steps", Readable: true},
|
||||
{Name: "pauses", Readable: true},
|
||||
}
|
||||
|
||||
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
|
||||
|
||||
@@ -45,16 +45,20 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
source = buildstep.SeatAsks{Language: delivery.AskNode}
|
||||
}
|
||||
capacity := m.Capacity
|
||||
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
|
||||
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
if capacity == nil {
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
watch = buildstep.WatchConfigServed
|
||||
}
|
||||
ask := m.askSteps(steps)
|
||||
var load func(string) ([]byte, error)
|
||||
if m.ReadSteps.Get != nil {
|
||||
load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) }
|
||||
if m.ReadPauses.Get != nil {
|
||||
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
|
||||
}
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, Load: load,
|
||||
Capacity: capacity, Logf: m.Say,
|
||||
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
|
||||
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Prepare(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -46,6 +46,8 @@ func main() {
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
Pauses: stdio.State(delivery.PausesState).Put,
|
||||
ReadPauses: readOf(stdio.State(delivery.PausesState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
Work: WorkDir,
|
||||
Run: forgekey.ExecRunnerClean(0),
|
||||
|
||||
@@ -94,9 +94,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
|
||||
states := []string{}
|
||||
for _, s := range m.State {
|
||||
states = append(states, s.Name)
|
||||
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
|
||||
if (s.Name == "steps") != (s.TTLSeconds > 0) {
|
||||
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
|
||||
}
|
||||
}
|
||||
slices.Sort(states)
|
||||
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
|
||||
t.Errorf("state = %v, want %v", states, want)
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
|
||||
|
||||
@@ -43,6 +43,9 @@ type Module struct {
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
||||
Pauses buildstep.Put
|
||||
ReadPauses StateRead
|
||||
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
|
||||
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
|
||||
Emit func(eventType string, body any) error
|
||||
|
||||
@@ -7,9 +7,12 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -39,6 +42,53 @@ func readOf(s stdio.KeptState) StateRead {
|
||||
}}
|
||||
}
|
||||
|
||||
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
|
||||
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
|
||||
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
|
||||
const (
|
||||
MaxStateReads = 1000
|
||||
stateReadParallel = 16
|
||||
)
|
||||
|
||||
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
|
||||
// stops starting reads after the first error and returns it.
|
||||
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
|
||||
out := make([]json.RawMessage, len(keys))
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
fail error
|
||||
next = make(chan int)
|
||||
)
|
||||
for w := 0; w < stateReadParallel && w < len(keys); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range next {
|
||||
raw, err := get(keys[i])
|
||||
mu.Lock()
|
||||
if err != nil && fail == nil {
|
||||
fail = err
|
||||
}
|
||||
mu.Unlock()
|
||||
out[i] = raw
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range keys {
|
||||
mu.Lock()
|
||||
stop := fail != nil
|
||||
mu.Unlock()
|
||||
if stop {
|
||||
break
|
||||
}
|
||||
next <- i
|
||||
}
|
||||
close(next)
|
||||
wg.Wait()
|
||||
return out, fail
|
||||
}
|
||||
|
||||
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
|
||||
func (m *Module) stepKey(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
@@ -80,27 +130,41 @@ type StepLine struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
|
||||
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
|
||||
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
|
||||
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
|
||||
// read. The values still order the lines, since an ask step's id carries the ask time.
|
||||
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
|
||||
return nil, fmt.Errorf("the steps state cannot be read here")
|
||||
return nil, false, fmt.Errorf("the steps state cannot be read here")
|
||||
}
|
||||
start := time.Now()
|
||||
keys, err := m.ReadSteps.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
own := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if strings.HasPrefix(k, m.Node+".") {
|
||||
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
|
||||
own = append(own, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(own)))
|
||||
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
|
||||
own, cut = own[:n], true
|
||||
}
|
||||
raws, err := readValues(own, m.ReadSteps.Get)
|
||||
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
|
||||
if err != nil {
|
||||
return nil, cut, err
|
||||
}
|
||||
var steps []delivery.Step
|
||||
for _, k := range keys {
|
||||
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
|
||||
continue
|
||||
}
|
||||
raw, err := m.ReadSteps.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range raws {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -122,7 +186,7 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
|
||||
@@ -130,24 +194,30 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
|
||||
return nil, fmt.Errorf("the toolchains state cannot be read here")
|
||||
}
|
||||
start := time.Now()
|
||||
keys, err := m.ReadToolchainsState.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cut := len(keys) > MaxStateReads
|
||||
if cut {
|
||||
keys = keys[:MaxStateReads] // a node has a key per language: far fewer in practice
|
||||
}
|
||||
raws, err := readValues(keys, m.ReadToolchainsState.Get)
|
||||
log.Printf("toolchains read: %d keys read in %v (cut %v: keys past %d were not read)", len(keys), time.Since(start).Round(time.Millisecond), cut, MaxStateReads)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]delivery.Toolchain{}
|
||||
for _, k := range keys {
|
||||
raw, err := m.ReadToolchainsState.Get(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, raw := range raws {
|
||||
if len(raw) == 0 {
|
||||
continue
|
||||
}
|
||||
t, err := delivery.DecodeToolchain(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", k, err)
|
||||
return nil, fmt.Errorf("%s: %w", keys[i], err)
|
||||
}
|
||||
out[k] = t
|
||||
out[keys[i]] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -171,8 +241,13 @@ func (m *Module) ReadTools() []stdio.Tool {
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
steps, err := m.RecentSteps(intArg(args["limit"]))
|
||||
return map[string]any{"node": m.Node, "steps": steps}, err
|
||||
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
|
||||
ans := map[string]any{"node": m.Node, "steps": steps}
|
||||
if cut {
|
||||
ans["cut"] = true
|
||||
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
|
||||
}
|
||||
return ans, err
|
||||
}},
|
||||
{Name: ToolchainsReadTool,
|
||||
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
|
||||
|
||||
@@ -3,8 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -145,3 +147,62 @@ func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
|
||||
t.Errorf("the read refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
|
||||
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
|
||||
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
|
||||
for _, row := range []struct {
|
||||
perNode, limit int
|
||||
reads int64
|
||||
steps int
|
||||
cut bool
|
||||
}{
|
||||
{20, 0, 20, 10, false},
|
||||
{600, 0, 100, 10, true},
|
||||
{1500, 300, MaxStateReads, 300, true},
|
||||
} {
|
||||
var reads, inFlight, maxFlight atomic.Int64
|
||||
stored := map[string][]byte{}
|
||||
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
||||
for i := 0; i < row.perNode; i++ {
|
||||
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
|
||||
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
|
||||
}
|
||||
}
|
||||
m := &Module{Node: "shanks"}
|
||||
m.ReadSteps = StateRead{
|
||||
Keys: func() ([]string, error) {
|
||||
var k []string
|
||||
for key := range stored {
|
||||
k = append(k, key)
|
||||
}
|
||||
return k, nil
|
||||
},
|
||||
Get: func(key string) (json.RawMessage, error) {
|
||||
reads.Add(1)
|
||||
now := inFlight.Add(1)
|
||||
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
inFlight.Add(-1)
|
||||
if !strings.HasPrefix(key, "shanks.") {
|
||||
t.Errorf("read %s, a key of another node", key)
|
||||
}
|
||||
return stored[key], nil
|
||||
},
|
||||
}
|
||||
start := time.Now()
|
||||
lines, cut, err := m.RecentSteps(row.limit)
|
||||
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
|
||||
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
|
||||
}
|
||||
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
|
||||
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
|
||||
}
|
||||
if maxFlight.Load() < 2 {
|
||||
t.Errorf("%d a node: values read one at a time", row.perNode)
|
||||
}
|
||||
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8 h1:x4mvY7yiGTP5lnzxbiwyj1o24p05hAmzZP7xHg3XX2M=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261025175617-518dae5ec9c8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"state": [
|
||||
"toolchains",
|
||||
"keys",
|
||||
"steps"
|
||||
{"name": "steps", "ttl-seconds": 172800}, "pauses"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,10 @@ is gone is read as the state it came to (merged, closed or proposed) when the ga
|
||||
|
||||
## When a refused or rejected merge is said (ADR 0505 §4, §5)
|
||||
|
||||
`stalled` is what the controller raises as a condition for the operator. The gate's own list (`show`, `deliveries`)
|
||||
`stalled` is what the gate says to the operator as conditions, itself, on the operator-channel seat (ADR 0437 §9, issue
|
||||
1275): after every look it raises each line, raises it again when its severity or need changes, and clears each that is
|
||||
listed no more; its bucket `conditions` keeps which it raised. (The controller's self-check that raised them went in
|
||||
release 6.) The gate's own list (`show`, `deliveries`)
|
||||
names every refusal and rejection at once, and the pull request is told at once; only `stalled` waits:
|
||||
|
||||
- a merge refused because its trunk moved, whose check the gate asked again, is stalled only once that check answered and
|
||||
|
||||
@@ -36,6 +36,15 @@ func TestTheBoundsAreSettingsWithTheirDefaults(t *testing.T) {
|
||||
if h.refusalFloor() != 10*time.Minute || h.authorBound() != 2*time.Hour {
|
||||
t.Fatalf("empty file: floor %s, author %s", h.refusalFloor(), h.authorBound())
|
||||
}
|
||||
if !h.checkGates() {
|
||||
t.Fatalf("check-gates-merge unset reads false; the default is true")
|
||||
}
|
||||
if s, err = read(`{"check-gates-merge": false}`); err != nil || s.CheckGatesMerge == nil || *s.CheckGatesMerge || h.checkGates() {
|
||||
t.Fatalf("check-gates-merge false: %+v %v, gates %v", s, err, h.checkGates())
|
||||
}
|
||||
if _, err := read(`{"check-gates-merge": "no"}`); err == nil {
|
||||
t.Fatalf("a check-gates-merge that is not true or false was taken")
|
||||
}
|
||||
if _, err := read(`{"author-new-head-minutes": 0}`); err == nil {
|
||||
t.Fatalf("a bound of zero minutes was taken")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The gate says its own stalled lines to the operator as conditions (novox/hq issue 1275, met again 2026-10-25; ADR 0437
|
||||
// §9: any module says a condition on the operator-channel seat's one subject, and the router alone chooses the channels).
|
||||
// Until release 6 the controller's self-check read the `stalled` verb and raised each line as `delivery.<id>.stalled`;
|
||||
// release 6 deleted that self-check (mesh-controller dc456d56) and nothing read the verb since, so a half-merged group was
|
||||
// said to no one. Now, after every look, the gate raises each line `stalled` lists, raises it again when its severity or
|
||||
// its need changes, and clears each it raised that is listed no more. What it raised is kept in its bucket conditions, so a
|
||||
// gate that restarts clears what ended while it was away.
|
||||
|
||||
// ConditionStore keeps the conditions the gate has raised: the condition's key to what it said (conditionSaid).
|
||||
type ConditionStore interface {
|
||||
Said() (map[string]string, error)
|
||||
PutSaid(said map[string]string) error
|
||||
}
|
||||
|
||||
// saidKey is the one key of the bucket conditions.
|
||||
const saidKey = "said"
|
||||
|
||||
// kvConditions is the bucket conditions over the runtime.
|
||||
type kvConditions struct{}
|
||||
|
||||
func (kvConditions) Said() (map[string]string, error) {
|
||||
e, err := stdio.State("conditions").Get(saidKey)
|
||||
if err != nil || e == nil {
|
||||
return map[string]string{}, err
|
||||
}
|
||||
said := map[string]string{}
|
||||
if err := json.Unmarshal(e.Value, &said); err != nil {
|
||||
return nil, fmt.Errorf("the conditions the gate raised are not readable: %w", err)
|
||||
}
|
||||
return said, nil
|
||||
}
|
||||
|
||||
func (kvConditions) PutSaid(said map[string]string) error {
|
||||
_, err := stdio.State("conditions").Put(saidKey, said)
|
||||
return err
|
||||
}
|
||||
|
||||
// sayOnSeat says a condition on the operator-channel seat under the gate's own name.
|
||||
func sayOnSeat(c asks.Condition) error {
|
||||
subject, err := asks.ConditionSubject("mesh-delivery-gate")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := asks.EncodeCondition(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return stdio.SeatPublish(subject, json.RawMessage(raw), "")
|
||||
}
|
||||
|
||||
// conditionKeyOf is the key of a stalled line's condition: its last part is the condition's kind.
|
||||
func conditionKeyOf(id string) string { return "mesh-delivery-gate.delivery." + id + ".stalled" }
|
||||
|
||||
// conditionSaid is what a raised condition said that, when it changes, raises it again: its severity and what it needs.
|
||||
// The words of a line hold durations, which change at every look and are no reason to tell the operator again.
|
||||
func conditionSaid(l StalledLine) string { return severityOfLine(l) + "|" + l.H2 + "|" + l.Needs }
|
||||
|
||||
func severityOfLine(l StalledLine) string {
|
||||
if l.Severity == asks.SeverityUrgent {
|
||||
return asks.SeverityUrgent
|
||||
}
|
||||
return asks.SeverityWarning
|
||||
}
|
||||
|
||||
// conditionOf is a stalled line as the condition the operator is told.
|
||||
func conditionOf(l StalledLine) asks.Condition {
|
||||
headline := "stalled: " + l.ID
|
||||
if strings.HasPrefix(l.Says, "group ") {
|
||||
group, _, _ := strings.Cut(strings.TrimPrefix(l.Says, "group "), " ")
|
||||
headline = "group " + group + " is not merged whole"
|
||||
}
|
||||
if r := []rune(headline); len(r) > asks.HeadlineLength {
|
||||
headline = string(r[:asks.HeadlineLength-1]) + "…"
|
||||
}
|
||||
needs := "read mesh-delivery.show " + l.ID + " and decide"
|
||||
return asks.Condition{Key: conditionKeyOf(l.ID), Severity: severityOfLine(l), State: asks.StateRaised,
|
||||
Subject: asks.Scope{Scope: "delivery", ID: l.ID}, Summary: l.Says, Headline: headline, Needs: needs,
|
||||
Explanation: asks.NeedsYou + needs + ". " + l.Says + " What ends it: " + l.H2 + ".",
|
||||
Resolved: "The gate lists " + l.ID + " as stalled no more."}
|
||||
}
|
||||
|
||||
// sayConditions raises and clears the conditions of the stalled lines (see above). Outside the lock; React runs it after
|
||||
// the look, so it follows every event. A condition that could not be said is said again at the next look, after
|
||||
// askAgainAfter at the latest.
|
||||
func (h *Holder) sayConditions() {
|
||||
if h.Say == nil || h.Conditions == nil {
|
||||
return
|
||||
}
|
||||
if h.said == nil {
|
||||
said, err := h.Conditions.Said()
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] the conditions raised before cannot be read, so none is said yet: %v; asked again in %s", err, askAgainAfter)
|
||||
h.again("the conditions the gate raised, read")
|
||||
return
|
||||
}
|
||||
h.said = said
|
||||
}
|
||||
h.mu.Lock()
|
||||
if at := h.nextStall(h.Now()); !at.IsZero() {
|
||||
h.lookAt(at, "a delivery whose stalled line, or its severity, comes due with no event")
|
||||
}
|
||||
forgeRead := h.forgeRead
|
||||
waitingOnForge := map[string]bool{}
|
||||
for id := range h.unchecked {
|
||||
waitingOnForge[id] = true
|
||||
}
|
||||
h.mu.Unlock()
|
||||
lines := map[string]StalledLine{}
|
||||
for _, l := range h.Stalled() {
|
||||
// One condition for an id: the most severe of its lines.
|
||||
if o, ok := lines[l.ID]; !ok || (severityOfLine(o) != asks.SeverityUrgent && severityOfLine(l) == asks.SeverityUrgent) {
|
||||
lines[l.ID] = l
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(lines))
|
||||
for id := range lines {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
changed, failed := false, false
|
||||
for _, id := range ids {
|
||||
l, key := lines[id], conditionKeyOf(id)
|
||||
said := conditionSaid(l)
|
||||
if h.said[key] == said {
|
||||
continue
|
||||
}
|
||||
if err := h.Say(conditionOf(l)); err != nil {
|
||||
h.Logf("[mesh-delivery] the condition of %s could not be said: %v", id, err)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s is said to the operator as %s: %s", id, severityOfLine(l), l.Says)
|
||||
h.said[key], changed = said, true
|
||||
}
|
||||
// Cleared only on what was read: not before the forge's open pull requests were read once since the gate started (the
|
||||
// heads waiting on the forge are kept in memory alone), and not while the forge still shows the head open and waiting:
|
||||
// its line is gone only because its bound runs again from a later first sight, and it comes back with the same words.
|
||||
var gone []string
|
||||
for key := range h.said {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
|
||||
if _, ok := lines[id]; !ok && forgeRead && !waitingOnForge[id] {
|
||||
gone = append(gone, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(gone)
|
||||
for _, key := range gone {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(key, "mesh-delivery-gate.delivery."), ".stalled")
|
||||
c := asks.Condition{Key: key, Subject: asks.Scope{Scope: "delivery", ID: id}, State: asks.StateCleared,
|
||||
Resolved: "The gate lists " + id + " as stalled no more."}
|
||||
if err := h.Say(c); err != nil {
|
||||
h.Logf("[mesh-delivery] the condition of %s could not be cleared: %v", id, err)
|
||||
failed = true
|
||||
continue
|
||||
}
|
||||
h.Logf("[mesh-delivery] the condition of %s is cleared: it is stalled no more", id)
|
||||
delete(h.said, key)
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := h.Conditions.PutSaid(h.said); err != nil {
|
||||
h.Logf("[mesh-delivery] the conditions raised cannot be kept: %v", err)
|
||||
}
|
||||
}
|
||||
if !failed {
|
||||
h.sayFailures = 0
|
||||
return
|
||||
}
|
||||
// A seat that keeps refusing is tried again after askAgainAfter, up to sayTries looks in a row; then only at the
|
||||
// next event, and said once, loudly, in the log (the operator hears nothing while the seat refuses).
|
||||
h.sayFailures++
|
||||
switch {
|
||||
case h.sayFailures < sayTries:
|
||||
h.again("a condition that could not be said to the operator")
|
||||
case h.sayFailures == sayTries:
|
||||
h.Logf("[mesh-delivery] NO CONDITION REACHES THE OPERATOR: the operator-channel seat refused %d looks in a row; "+
|
||||
"tried again at each event from now on", sayTries)
|
||||
}
|
||||
}
|
||||
|
||||
// sayTries is how many looks in a row a condition that could not be said is tried again on the clock.
|
||||
const sayTries = 10
|
||||
|
||||
// nextStall is the earliest time after now when a delivery's stalled line comes due, or its severity changes, with nothing
|
||||
// else happening: a state's bound, the author's bound of a rejected head, a head never announced, a refusal the gate
|
||||
// answers, and an out-of-date refusal turning urgent (1275/AC6). Zero when none. Under the lock.
|
||||
func (h *Holder) nextStall(now time.Time) time.Time {
|
||||
var next time.Time
|
||||
consider := func(at time.Time) {
|
||||
if at.After(now) && (next.IsZero() || at.Before(next)) {
|
||||
next = at
|
||||
}
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() {
|
||||
continue
|
||||
}
|
||||
if b, bounded := boundOf(d); bounded && b.For > 0 {
|
||||
consider(boundSince(d).Add(b.For).Add(time.Second)) // pastBound is strictly after the bound
|
||||
}
|
||||
r := d.Ready
|
||||
if r == nil || r.Merged != nil || d.MergedAs != "" {
|
||||
continue
|
||||
}
|
||||
if d.Base == "" && d.Number != 0 {
|
||||
consider(r.Seen.Add(headHeardBound))
|
||||
}
|
||||
if r.Failed != "" && d.State == Rejected {
|
||||
consider(d.Since.Add(h.authorBound()))
|
||||
}
|
||||
if r.RefusedSince != nil {
|
||||
consider(r.RefusedSince.Add(h.answeredRefusalBound(d.Repository)))
|
||||
consider(r.RefusedSince.Add(outOfDateFor))
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -67,7 +67,9 @@ func (h *Holder) CommitStatus(e ForgeStatusEvent) {
|
||||
h.takeCheckStatus(e.Owner+"/"+e.Repo, e.SHA, e.State, e.Description)
|
||||
}
|
||||
if e.State != "success" {
|
||||
h.withdrawForStatus(e)
|
||||
if h.checkGates() {
|
||||
h.withdrawForStatus(e) // while the check gates no merge, its status is shown and takes back no ask
|
||||
}
|
||||
return
|
||||
}
|
||||
repository := strings.ToLower(e.Owner + "/" + e.Repo)
|
||||
|
||||
@@ -63,7 +63,8 @@ func (h *Holder) unmergedLive(g *Group) []*Delivery {
|
||||
|
||||
// groupMergeable reads every member's required statuses at the head the Approve covers, and says whether all are a success.
|
||||
// When one is not, or cannot be read, nothing is handed to the bridge: the group is held, said urgent (ADR 0441 §2, §4), and
|
||||
// the target's turn is given back so the next event that decides whether it may merge asks it again. Outside the lock.
|
||||
// the target's turn is given back so the next event that decides whether it may merge asks it again. A read that could not
|
||||
// be made is a failed ask, tried again after askAgainAfter. Outside the lock.
|
||||
func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
release := func() {
|
||||
if d := h.deliveries[target]; d != nil && d.Ready != nil {
|
||||
@@ -98,8 +99,9 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
g.Hold = &GroupHold{Kind: "blocked", Member: m.id, Says: says, Since: since}
|
||||
h.keepGroup(g)
|
||||
}
|
||||
// No timer: the status turning success is an event on the forge (CommitStatus, mergeAgainNow), and it asks the
|
||||
// merge again at once (issue 1275: a pass that stops part way resumes on an event).
|
||||
release()
|
||||
h.again("a group's merge held for a required status")
|
||||
h.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
@@ -112,7 +114,7 @@ func (h *Holder) groupMergeable(gid, target string, reads []memberRead) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the controller raises as conditions:
|
||||
// groupLines are the groups that are held or half merged, as urgent stalled lines the gate says as conditions (conditions.go):
|
||||
// one for each, on its first member not merged, until every member is merged or a person decides (issue 1275). A member
|
||||
// the merge tool refused is said by readyLines, with its group; this adds a group held for a required status, and a
|
||||
// group half merged with no refusal. Under the lock.
|
||||
@@ -141,12 +143,16 @@ func (h *Holder) groupLines(now time.Time) []StalledLine {
|
||||
switch {
|
||||
// A held member the merge tool refused is said by readyLines already: one line for an id, not two.
|
||||
case g.Hold != nil && g.Hold.Kind == "blocked" && !running && !(heldOn != nil && heldOn.Ready != nil && heldOn.Ready.RefusedSince != nil):
|
||||
stands := "is approved and nothing is merged"
|
||||
if merged > 0 {
|
||||
stands = "is half merged" // a member merged before the pass stopped (issue 1275)
|
||||
}
|
||||
out = append(out, StalledLine{ID: g.Hold.Member, Number: numberOf(h.deliveries[g.Hold.Member]), State: first.State,
|
||||
For: now.Sub(g.Hold.Since).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
H2: "none: the required status turns success, which asks the merge again — or the operator's decision",
|
||||
Says: fmt.Sprintf("group %s is approved and nothing is merged (merged %d of %d): %s waits on a required status "+
|
||||
Says: fmt.Sprintf("group %s %s (merged %d of %d): %s waits on a required status "+
|
||||
"at its head: %s. Needed: that status turns success (its check asked again), or the operator decides.",
|
||||
g.ID, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
g.ID, stands, merged, len(h.membersOf(g)), g.Hold.Member, g.Hold.Says)})
|
||||
case merged > 0 && !refused && !running:
|
||||
out = append(out, StalledLine{ID: first.ID, Number: first.Number, State: first.State,
|
||||
For: now.Sub(g.Updated).Round(time.Second).String(), Bound: "0s", Severity: "urgent",
|
||||
|
||||
@@ -21,25 +21,44 @@ const (
|
||||
// approvedGroup is a group of two pull requests on one branch, checks green, composed green and the operator's Approve
|
||||
// asked and not yet answered (w.op.auto is off).
|
||||
func approvedGroup(t *testing.T) *world {
|
||||
return approvedGroupOf(t, member{gCtl, 1, gHc, "internal/a.go"}, member{gCat, 2, gHk, "modules/app/module.json"})
|
||||
}
|
||||
|
||||
// member is a pull request of a group in feat/g: its repository, number, head and the paths it changes.
|
||||
type member struct {
|
||||
repo string
|
||||
number int
|
||||
head string
|
||||
paths string
|
||||
}
|
||||
|
||||
// approvedGroupOf is a group feat/g of these members, each checked, composed together and asked once.
|
||||
func approvedGroupOf(t *testing.T, ms ...member) *world {
|
||||
t.Helper()
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
w.h.PullUpdated(pr(gCtl, 1, gHc, "feat/g", "internal/a.go"))
|
||||
w.h.PullUpdated(pr(gCat, 2, gHk, "feat/g", "modules/app/module.json"))
|
||||
w.h.Checked(judged(gCtl, 1, gHc, "pass", gTrunk0))
|
||||
w.h.Checked(judged(gCat, 2, gHk, "pass", gTrunk0))
|
||||
for _, m := range ms {
|
||||
w.h.PullUpdated(pr(m.repo, m.number, m.head, "feat/g", strings.Split(m.paths, ",")...))
|
||||
}
|
||||
for _, m := range ms {
|
||||
w.h.Checked(judged(m.repo, m.number, m.head, "pass", gTrunk0))
|
||||
}
|
||||
w.settleAll()
|
||||
g := w.h.groups["feat/g"]
|
||||
if g == nil || g.Check == nil {
|
||||
t.Fatalf("no composed check was asked: %+v", g)
|
||||
}
|
||||
w.h.Checked(CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed",
|
||||
Members: []struct {
|
||||
c := CheckedEvent{Group: "feat/g", ID: g.Check.Asked, Verdict: "pass", Summary: "composed"}
|
||||
for _, m := range ms {
|
||||
owner, repo, _ := strings.Cut(m.repo, "/")
|
||||
c.Members = append(c.Members, struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
Commit string `json:"commit"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}{{Owner: "novox", Repo: "mesh-controller", Commit: gHc, Trunk: gTrunk0[:12]},
|
||||
{Owner: "novox", Repo: "mesh-catalog", Commit: gHk, Trunk: gTrunk0[:12]}}})
|
||||
}{Owner: owner, Repo: repo, Commit: m.head, Trunk: gTrunk0[:12]})
|
||||
}
|
||||
w.h.Checked(c)
|
||||
w.settleAll()
|
||||
if w.op.count() != 1 || len(w.op.open()) != 1 {
|
||||
t.Fatalf("the green group is asked once: %d asks, open %v", w.op.count(), w.op.open())
|
||||
|
||||
@@ -16,6 +16,12 @@ func verdictMoving(repo string, number int, sha string, _ ...string) CheckedEven
|
||||
return verdict(repo, number, sha, "pass")
|
||||
}
|
||||
|
||||
// ungated is the setting check-gates-merge false: mesh/check gates no merge (the operator, 2026-10-25 22:56).
|
||||
func ungated(w *world) {
|
||||
off := false
|
||||
w.h.Settings = func() Settings { return Settings{CheckGatesMerge: &off} }
|
||||
}
|
||||
|
||||
func statusOn(w *world, repo, commit string) string {
|
||||
w.forge.mu.Lock()
|
||||
defer w.forge.mu.Unlock()
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// The holder of the mesh-delivery seat (novox/hq ADR 0239, to-be 47): the one writer of deliveries and
|
||||
@@ -37,6 +39,15 @@ type Holder struct {
|
||||
Owed OwedStore
|
||||
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
|
||||
Pulls PullKeys
|
||||
// Say says a condition to the operator, and Conditions keeps which the gate raised (conditions.go); nil says none.
|
||||
Say func(c asks.Condition) error
|
||||
Conditions ConditionStore
|
||||
// said is what Conditions keeps, read at the first look; sayFailures the looks in a row a condition could not be
|
||||
// said. Touched by React alone.
|
||||
said map[string]string
|
||||
sayFailures int
|
||||
// forgeRead is whether the forge's open pull requests were read since this owner started (SeeOpenPulls).
|
||||
forgeRead bool
|
||||
// cancels are asks to take back, gathered under the lock and run with the other asks of the look.
|
||||
cancels []func()
|
||||
|
||||
@@ -250,7 +261,7 @@ func (h *Holder) keepGroup(g *Group) {
|
||||
|
||||
// settleAndKeep moves a delivery as far as its facts allow, and keeps it.
|
||||
func (h *Holder) settleAndKeep(d *Delivery) {
|
||||
ts := settle(d, Facts{Now: h.Now()})
|
||||
ts := settle(d, h.factsAt(h.Now()))
|
||||
h.moved(d, ts)
|
||||
h.closeGroupOf(d)
|
||||
}
|
||||
@@ -367,7 +378,7 @@ func (h *Holder) pullUpdated(p PullEvent) (isNew bool) {
|
||||
// the gate's status on that very commit. A decided one is taken; a pending or errored one is asked again.
|
||||
if v := decidedOnHead(p.HeadChecks, now); v != nil {
|
||||
d.Check = v
|
||||
ts = append(ts, settle(d, Facts{Now: now})...)
|
||||
ts = append(ts, settle(d, h.factsAt(now))...)
|
||||
}
|
||||
h.moved(d, ts)
|
||||
if !h.takePlaceOf(replaced, d) {
|
||||
@@ -635,7 +646,12 @@ func (h *Holder) leaveGroup(d *Delivery) {
|
||||
|
||||
// PullMerged takes a merge: the commit it landed as, and the delivery it was — made from the forge's word
|
||||
// when this owner never heard its head (merged before it held the seat).
|
||||
func (h *Holder) PullMerged(p PullEvent) {
|
||||
func (h *Holder) PullMerged(p PullEvent) { h.pullMerged(p, false) }
|
||||
|
||||
// pullMerged takes a merge. late is a merge learnt after the fact (settleGone): its own delivery is merged and its group
|
||||
// closed, but no other delivery's trunk moves (the trunk has long moved past it, by merges heard as they happened),
|
||||
// its time is the forge's or none (never now), and it never moves source.main (LearntLate).
|
||||
func (h *Holder) pullMerged(p PullEvent, late bool) {
|
||||
if p.MergeCommit == "" {
|
||||
h.Refuse(errors.New("a merge announced with no merge commit; nothing published"))
|
||||
return
|
||||
@@ -684,7 +700,7 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
}
|
||||
d, ts = fresh, []Transition{t}
|
||||
if d.Check != nil {
|
||||
ts = append(ts, settle(d, Facts{Now: now})...)
|
||||
ts = append(ts, settle(d, h.factsAt(now))...)
|
||||
}
|
||||
} else if d == nil {
|
||||
head := p.HeadSHA
|
||||
@@ -710,16 +726,31 @@ func (h *Holder) PullMerged(p PullEvent) {
|
||||
d.MergedAs = p.MergeCommit
|
||||
if t, err := time.Parse(time.RFC3339, p.MergedAt); err == nil {
|
||||
d.MergedAt = t.UTC()
|
||||
} else {
|
||||
if late && now.Sub(d.MergedAt) < replayLate {
|
||||
// Read from the forge's open pull requests seconds after the merge, before its event arrived (mesh-delivery#51,
|
||||
// 2026-10-25 21:43: merged 21:43:11, learnt late 21:43:14): the trunk has not moved past it, so it is taken as a
|
||||
// merge heard as it happened. Taken late, it moved no trunk and its group's finish left it out of source.main,
|
||||
// which kept naming the trunk before it, and the checker judged every head on that.
|
||||
late = false
|
||||
}
|
||||
} else if !late {
|
||||
d.MergedAt = now
|
||||
}
|
||||
ts = append(ts, settle(d, Facts{Now: now})...)
|
||||
if late {
|
||||
d.LearntLate, d.InSource = true, true
|
||||
if d.Ready != nil {
|
||||
d.Ready.SourceMoved = true
|
||||
}
|
||||
}
|
||||
ts = append(ts, settle(d, h.factsAt(now))...)
|
||||
if d.State == Held && d.HeldWhy == "" {
|
||||
d.HeldWhy = "merged without a passing check: only a person decides what follows"
|
||||
}
|
||||
h.moved(d, ts)
|
||||
h.closeGroupOf(d)
|
||||
h.trunkMoved(d, p.Base, p.MergeCommit, now)
|
||||
if !late {
|
||||
h.trunkMoved(d, p.Base, p.MergeCommit, now)
|
||||
}
|
||||
}
|
||||
|
||||
// trunkMoved takes a merge into a trunk as that trunk's move (novox/hq ADR 0323 §7): every other open delivery into
|
||||
@@ -921,7 +952,7 @@ func (h *Holder) askChecks(now time.Time) func() {
|
||||
}
|
||||
}
|
||||
|
||||
// membersOf are a group's current members, by id order.
|
||||
// membersOf are a group's current members, in the order they merge (mergeRank, then id).
|
||||
func (h *Holder) membersOf(g *Group) []*Delivery {
|
||||
var out []*Delivery
|
||||
for _, id := range g.Members {
|
||||
@@ -929,9 +960,39 @@ func (h *Holder) membersOf(g *Group) []*Delivery {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if ri, rj := mergeRank(out[i]), mergeRank(out[j]); ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SDKRepository is the repository of mesh-sdk, the library the other repositories' modules pin by commit (novox/hq ADR 0482 §3).
|
||||
const SDKRepository = "novox/mesh-sdk"
|
||||
|
||||
// mergeRank is a member's place in its group's merge (novox/hq issue 1275): a member another member pins merges before it, so
|
||||
// that the trunk never holds a pin of a commit not on its own trunk. The gate knows a pin by the paths a pull request changes:
|
||||
// mesh-sdk's member goes first (the members of the other repositories pin it, ADR 0482 §3); then the members that change no
|
||||
// go.mod or go.sum (they pin nothing new); then those that do (they pin another member's commit). The id orders a tie. The
|
||||
// module graph is the fuller source (which module requires which), and is not the gate's to read: this is what its own
|
||||
// deliveries say.
|
||||
func mergeRank(d *Delivery) int {
|
||||
if strings.EqualFold(d.Repository, SDKRepository) {
|
||||
return 0
|
||||
}
|
||||
if d.PathsTruncated || len(d.Paths) == 0 {
|
||||
return 2 // its paths are not all known: it may pin, so it goes after those that do not
|
||||
}
|
||||
for _, p := range d.Paths {
|
||||
if base := p[strings.LastIndex(p, "/")+1:]; base == "go.mod" || base == "go.sum" {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// planGroup is what a group needs asked, and its turns.
|
||||
func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func() {
|
||||
var asks []func()
|
||||
@@ -956,7 +1017,7 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
if held {
|
||||
status = "pending" // no Approve exists while a member waits for its review (novox/hq ADR 0496 §2)
|
||||
}
|
||||
if state == "merging" && !(g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
|
||||
if state == GroupMerging && !(g.Check != nil && (g.Check.Verdict == "pass" || g.Check.Verdict == "warning") &&
|
||||
sameMembers(g.Check.Commits, commitsOf(members)) && g.Check.OutOfDate == "") {
|
||||
// Success there lets the merge tool merge its members: only for a composed check that passed for these heads.
|
||||
status = "pending"
|
||||
@@ -980,6 +1041,23 @@ func (h *Holder) planGroup(g *Group, members []*Delivery, now time.Time) []func(
|
||||
return asks
|
||||
}
|
||||
|
||||
// The words GroupState says a group's state with (ADR 0539 §17). Code that decides on a group's state compares with these,
|
||||
// never with a literal: a word GroupState no longer says ("delivering", retired in release 6) left the second member of
|
||||
// every approved group waiting for ever (novox/hq issue 1275, met again 2026-10-25). groupWords lists them all, and a test
|
||||
// holds every decision on a group's state to that list.
|
||||
const (
|
||||
GroupChecking = "checking"
|
||||
GroupRejected = "rejected"
|
||||
GroupReady = "ready"
|
||||
GroupMerging = "merging"
|
||||
GroupMerged = "merged"
|
||||
GroupHeld = "held"
|
||||
GroupClosed = "closed"
|
||||
)
|
||||
|
||||
// groupWords are every word GroupState says.
|
||||
var groupWords = []string{GroupChecking, GroupRejected, GroupReady, GroupMerging, GroupMerged, GroupHeld, GroupClosed}
|
||||
|
||||
// GroupState is a group's state, derived from its members and its composed check — never set apart (novox/hq
|
||||
// ADR 0239 decision 4, cut to the merge's states by ADR 0539 §17): checking, rejected, ready, merging (some members
|
||||
// merged), merged, held and closed. Nothing after the merge is derived here: whether a merged change reached the machines is
|
||||
@@ -992,36 +1070,36 @@ func GroupState(g *Group, members []*Delivery) (string, string) {
|
||||
n := len(members)
|
||||
switch {
|
||||
case count[Held] > 0 && g.Closed:
|
||||
return "held", "a member is held: " + heldWords(members)
|
||||
return GroupHeld, "a member is held: " + heldWords(members)
|
||||
case count[Closed] > 0 && g.Closed:
|
||||
return "closed", "a member was closed before the group merged"
|
||||
return GroupClosed, "a member was closed before the group merged"
|
||||
case n > 0 && count[Merged] == n:
|
||||
return "merged", "every member is merged"
|
||||
return GroupMerged, "every member is merged"
|
||||
case g.Closed && g.Check != nil && (g.Check.OutOfDate != "" || g.Check.Verdict == "" ||
|
||||
!sameMembers(g.Check.Commits, commitsOf(members))):
|
||||
// A member rebased or pushed once the group was merging, or its composed check out of date: it is composed
|
||||
// again for these heads, and the group is checking until then, never held (novox/hq ADR 0323 §6, issue 406).
|
||||
if g.Check.OutOfDate != "" {
|
||||
return "checking", g.Check.OutOfDate
|
||||
return GroupChecking, g.Check.OutOfDate
|
||||
}
|
||||
return "checking", "composed again for its members' new heads"
|
||||
return GroupChecking, "composed again for its members' new heads"
|
||||
case g.Closed && g.Check != nil && g.Check.Verdict != "pass" && g.Check.Verdict != "warning":
|
||||
// Composed again for new heads once it was merging, and those do not compose: its members wait (issue 902).
|
||||
return "rejected", "composed together: " + g.Check.Summary
|
||||
return GroupRejected, "composed together: " + g.Check.Summary
|
||||
case g.Closed:
|
||||
return "merging", fmt.Sprintf("%d of %d merged", count[Merged], n)
|
||||
return GroupMerging, fmt.Sprintf("%d of %d merged", count[Merged], n)
|
||||
case count[Rejected] > 0:
|
||||
return "rejected", "a member's own check failed"
|
||||
return GroupRejected, "a member's own check failed"
|
||||
case g.Check != nil && g.Check.OutOfDate != "":
|
||||
// Out of date counts as outstanding (ADR 0239 §18, novox/hq ADR 0323 §7): checking, saying why.
|
||||
return "checking", g.Check.OutOfDate
|
||||
return GroupChecking, g.Check.OutOfDate
|
||||
case g.Check != nil && g.Check.Verdict != "" && g.Check.Verdict != "pass" && g.Check.Verdict != "warning":
|
||||
return "rejected", "composed together: " + g.Check.Summary
|
||||
return GroupRejected, "composed together: " + g.Check.Summary
|
||||
case count[Ready] == n && g.Check != nil && sameMembers(g.Check.Commits, commitsOf(members)) &&
|
||||
(g.Check.Verdict == "pass" || g.Check.Verdict == "warning"):
|
||||
return "ready", "every member ready, and composed together they pass"
|
||||
return GroupReady, "every member ready, and composed together they pass"
|
||||
}
|
||||
return "checking", fmt.Sprintf("%d of %d member(s) ready", count[Ready], n)
|
||||
return GroupChecking, fmt.Sprintf("%d of %d member(s) ready", count[Ready], n)
|
||||
}
|
||||
|
||||
// heldWords says the held members and why.
|
||||
@@ -1040,7 +1118,7 @@ func heldWords(members []*Delivery) string {
|
||||
// says its mesh/delivery-group is pending, not a success. Under the lock.
|
||||
func (h *Holder) groupState(g *Group, members []*Delivery) (state, why string, held bool) {
|
||||
state, why = GroupState(g, members)
|
||||
if state != "ready" || g.Closed {
|
||||
if state != GroupReady || g.Closed {
|
||||
return state, why, false
|
||||
}
|
||||
var waiting []string
|
||||
@@ -1063,11 +1141,11 @@ func (h *Holder) groupState(g *Group, members []*Delivery) (state, why string, h
|
||||
// heads (novox/hq ADR 0382), and a pending one there would hold them for ever (novox/hq issue 902).
|
||||
func groupStatusState(s string) string {
|
||||
switch s {
|
||||
case "ready", "merging", "merged":
|
||||
case GroupReady, GroupMerging, GroupMerged:
|
||||
return "success"
|
||||
case "rejected":
|
||||
case GroupRejected:
|
||||
return "failure"
|
||||
case "held", "closed":
|
||||
case GroupHeld, GroupClosed:
|
||||
return "error"
|
||||
}
|
||||
return "pending"
|
||||
|
||||
@@ -2,9 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const time30s = 31 * time.Second
|
||||
@@ -75,24 +80,6 @@ func TestAnApprovedGroupWithAMemberWhoseRequiredStatusIsNotASuccessMergesNothing
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said urgent,
|
||||
// naming the group, the refused member and the forge's words.
|
||||
func TestARefusedFirstMemberStopsTheGroupAndIsSaidUrgent(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
||||
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// 1275/AC4: a group half merged (the second member refused after the first merged) stays raised, pass after pass, until
|
||||
// the second is merged; then it goes.
|
||||
func TestAHalfMergedGroupStaysRaisedUntilEveryMemberIsMerged(t *testing.T) {
|
||||
@@ -344,3 +331,440 @@ func TestASeverityOnceUrgentNeverStepsDownToAWarning(t *testing.T) {
|
||||
t.Fatalf("severity %v after an out of date refusal that followed an urgent one; wanted urgent", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeConditions is the gate's bucket conditions and the operator-channel seat's subject for conditions: what it keeps,
|
||||
// and every condition said, in order.
|
||||
type fakeConditions struct {
|
||||
mu sync.Mutex
|
||||
kept map[string]string
|
||||
told []asks.Condition
|
||||
}
|
||||
|
||||
func (f *fakeConditions) Said() (map[string]string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for k, v := range f.kept {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeConditions) PutSaid(said map[string]string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.kept = map[string]string{}
|
||||
for k, v := range said {
|
||||
f.kept[k] = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeConditions) say(c asks.Condition) error {
|
||||
if _, err := asks.EncodeCondition(c); err != nil {
|
||||
return err // what the router would refuse
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.told = append(f.told, c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// last is the newest condition said for a delivery id, or nil.
|
||||
func (f *fakeConditions) last(id string) *asks.Condition {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := len(f.told) - 1; i >= 0; i-- {
|
||||
if f.told[i].Subject.ID == id {
|
||||
c := f.told[i]
|
||||
return &c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hearConditions has the gate say its conditions into a fake seat and bucket.
|
||||
func hearConditions(w *world) *fakeConditions {
|
||||
f := &fakeConditions{kept: map[string]string{}}
|
||||
w.h.Say, w.h.Conditions = f.say, f
|
||||
return f
|
||||
}
|
||||
|
||||
// handedInOrder answers each member the gate hands the forge bridge as the forge does on 2026-10-25: the forge's word that
|
||||
// it merged (gitea.pull.merged) first, the bridge's word after; or the bridge's first, as forgeFirst says. It returns the
|
||||
// repositories in the order they were handed.
|
||||
func handedInOrder(w *world, forgeFirst bool) []string {
|
||||
var handed []string
|
||||
numbers := map[string]int{}
|
||||
for i := 0; i < 4; i++ {
|
||||
var next *sdk.MergePull
|
||||
for _, p := range w.merges.last("feat/g").Pulls {
|
||||
if p.State == sdk.MergeMerging {
|
||||
p := p
|
||||
next = &p
|
||||
}
|
||||
}
|
||||
if next == nil || numbers[next.Repository] > 0 {
|
||||
break
|
||||
}
|
||||
handed = append(handed, next.Repository)
|
||||
numbers[next.Repository] = next.Number
|
||||
mergeCommit := strings.Repeat(string(rune('a'+i)), 40)
|
||||
forge := func() { w.h.PullMerged(merged(next.Repository, next.Number, next.Head.Head, mergeCommit)) }
|
||||
bridge := func() { bridgeMerged(w, next.Repository, next.Number, next.Head.Head, mergeCommit) }
|
||||
if forgeFirst {
|
||||
forge()
|
||||
w.settleAll() // ten seconds pass between the two words on 2026-10-25: the gate looks in between
|
||||
bridge()
|
||||
} else {
|
||||
bridge()
|
||||
w.settleAll()
|
||||
forge()
|
||||
}
|
||||
w.settleAll()
|
||||
}
|
||||
return handed
|
||||
}
|
||||
|
||||
// Issue 1275, met again 2026-10-25 (group fix/puller-wakes-on-capacity): every member of an approved group is handed to
|
||||
// the forge bridge, in the group's order, the next on the event that the one before merged; a group whose pass stops is
|
||||
// said to the operator as a condition, which is cleared once it is whole; and every word a group's state is said in is
|
||||
// one the merge decides on.
|
||||
func TestAnApprovedGroupIsHandedWholeInItsOrderAndAStopIsSaid(t *testing.T) {
|
||||
const (
|
||||
sdkRepo, hS = "novox/mesh-sdk", "a8fbabbfc37369f9d1f6977c72e5523aff3d494c"
|
||||
catRepo, hK = "novox/mesh-catalog", "23c827e7910c233608af4e4a8056cbc362dcb755"
|
||||
ctlRepo, hC = "novox/mesh-controller", "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0"
|
||||
)
|
||||
sdkMember := member{sdkRepo, 80, hS, "go/stdio.go,go/buildstep/pull.go"}
|
||||
catMember := member{catRepo, 479, hK, "modules/mesh-build-go/asks.go,modules/mesh-build-go/go.mod,modules/mesh-build-go/go.sum"}
|
||||
ctlMember := member{ctlRepo, 7, hC, "cmd/mesh-controller/main.go"}
|
||||
inOrder := func(members []member, forgeFirst bool, want ...string) func(t *testing.T) {
|
||||
return func(t *testing.T) {
|
||||
w := approvedGroupOf(t, members...)
|
||||
w.merges.manual = true
|
||||
allGreenFor(w, members...)
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if got := handedInOrder(w, forgeFirst); strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("handed %v, wanted %v, every member in the group's order", got, want)
|
||||
}
|
||||
if g := w.h.groups["feat/g"]; g == nil {
|
||||
t.Fatal("the group is gone")
|
||||
} else if state, why := GroupState(g, w.h.membersOf(g)); state != GroupMerged {
|
||||
t.Fatalf("the group is %s (%s), not merged whole", state, why)
|
||||
}
|
||||
if w.source.main == nil || len(w.source.main.Heads) != len(want) {
|
||||
t.Fatalf("source.main does not hold the group as one revision: %+v", w.source.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T)
|
||||
}{
|
||||
// 1275/AC3 (2026-10-25): the case of the day. The catalogue member pins the sdk member's commit; the forge's word
|
||||
// of a merge arrives before the bridge's. Before the fix the catalogue member went first, and the sdk member was
|
||||
// never handed: its group read merging, a word the turn to merge did not know.
|
||||
{"sdk first, then the member that pins it, the forge's word first", inOrder([]member{catMember, sdkMember}, true, sdkRepo, catRepo)},
|
||||
{"sdk first, then the member that pins it, the bridge's word first", inOrder([]member{sdkMember, catMember}, false, sdkRepo, catRepo)},
|
||||
{"a member that pins nothing before one that changes a go.mod", inOrder([]member{catMember, ctlMember}, true, ctlRepo, catRepo)},
|
||||
{"three members", inOrder([]member{ctlMember, catMember, sdkMember}, true, sdkRepo, ctlRepo, catRepo)},
|
||||
// 1275/AC3: the merge tool refuses the first member in order; the second is not merged, and the group is said
|
||||
// urgent, naming the group, the refused member and the forge's words, as a stalled line and to the operator.
|
||||
{"a refused first member stops the group and is said urgent", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.forge.refuseRepo = map[string]error{gCat: refusedWords("novox/mesh-catalog#2")}
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
if len(w.forge.merges) != 1 || !strings.Contains(w.forge.merges[0], "mesh-catalog#2") {
|
||||
t.Fatalf("wanted only the first member asked: %v", w.forge.merges)
|
||||
}
|
||||
lines := urgentLines(w)
|
||||
if len(lines) != 1 || lines[0].ID != IDOf(gCat, gHk) || !strings.Contains(lines[0].Says, "feat/g") ||
|
||||
!strings.Contains(lines[0].Says, "mesh/repo-check (missing)") || !strings.Contains(lines[0].Says, "Needed") {
|
||||
t.Fatalf("not said as one urgent line: %+v", lines)
|
||||
}
|
||||
if c := told.last(IDOf(gCat, gHk)); c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent ||
|
||||
!strings.Contains(c.Summary, "feat/g") {
|
||||
t.Fatalf("the operator was not told, urgent: %+v", c)
|
||||
}
|
||||
}},
|
||||
// 1275/AC4: a group half merged (its pass stopped after the first member) raises one urgent condition, said once
|
||||
// however many looks follow, and cleared once the group is whole.
|
||||
{"a half-merged group is raised once and cleared once whole", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
w.h.PullMerged(merged(gCat, 2, gHk, gT1))
|
||||
bridgeMerged(w, gCat, 2, gHk, gT1)
|
||||
setChecks(w, gCtl, gHc, "success", "") // the second member's status went missing: the pass stops at it
|
||||
w.settleAll()
|
||||
for range 3 {
|
||||
w.later(time30s)
|
||||
w.settleAll()
|
||||
}
|
||||
id := IDOf(gCtl, gHc)
|
||||
raised := 0
|
||||
for _, c := range told.told {
|
||||
if c.Subject.ID == id && c.State == asks.StateRaised {
|
||||
raised++
|
||||
}
|
||||
}
|
||||
if c := told.last(id); raised != 1 || c == nil || c.Severity != asks.SeverityUrgent || !strings.Contains(c.Summary, "feat/g") ||
|
||||
!strings.Contains(c.Summary, "half merged (merged 1 of 2)") {
|
||||
t.Fatalf("raised %d times, last %+v; wanted once, urgent, naming the group half merged", raised, c)
|
||||
}
|
||||
setChecks(w, gCtl, gHc, "success", "success")
|
||||
w.h.CommitStatus(ForgeStatusEvent{Owner: "novox", Repo: "mesh-controller", SHA: gHc, Context: repoCheck, State: "success"})
|
||||
w.settleAll() // the status is an event: the pass goes on with no timer
|
||||
handed := false
|
||||
for _, p := range w.merges.last("feat/g").Pulls {
|
||||
handed = handed || (p.Repository == gCtl && p.State == sdk.MergeMerging)
|
||||
}
|
||||
if !handed {
|
||||
t.Fatalf("the status turned success and the second member was not handed: %+v", w.merges.last("feat/g"))
|
||||
}
|
||||
w.h.PullMerged(merged(gCtl, 1, gHc, gT1))
|
||||
bridgeMerged(w, gCtl, 1, gHc, gT1)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
||||
t.Fatalf("the group is whole and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
// A condition the gate raised before it restarted, whose delivery stalls no more, is cleared at the first look.
|
||||
{"a condition kept from before a restart is cleared", func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
told := hearConditions(w)
|
||||
told.kept[conditionKeyOf("novox/app@0123456789ab")] = "urgent||"
|
||||
w.settleAll()
|
||||
if c := told.last("novox/app@0123456789ab"); c != nil {
|
||||
t.Fatalf("cleared before the forge's open pull requests were read: %+v", c)
|
||||
}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if c := told.last("novox/app@0123456789ab"); c == nil || c.State != asks.StateCleared || len(told.kept) != 0 {
|
||||
t.Fatalf("not cleared: %+v, kept %v", c, told.kept)
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 1: a pull request closed or merged on the forge with no event heard (de-spiegel #4,
|
||||
// closed 2026-10-17, ready here eight days later) settles at the next complete read of the open list, and no
|
||||
// condition is raised for it; a merge learnt so does not move source.main.
|
||||
{"a pull request gone from the open list settles and raises nothing", func(t *testing.T) {
|
||||
for _, merged := range []bool{false, true} {
|
||||
w := newWorld(t)
|
||||
w.op.auto = false
|
||||
const repo, head = "novox/de-spiegel", "fdcbaefb3123fdcbaefb3123fdcbaefb3123fdcb"
|
||||
w.h.PullUpdated(pr(repo, 4, head, "feat/x", "src/a.go"))
|
||||
w.h.Checked(judgedAt(repo, 4, head, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.forge.refuse = errors.New("novox/de-spiegel#4 merges into main, which no protection rule requiring the checks guards")
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
id := IDOf(repo, head)
|
||||
if len(w.h.Stalled()) == 0 {
|
||||
t.Fatalf("the refused head is not stalled to begin with")
|
||||
}
|
||||
p := ForgePull{Number: 4, State: "closed", HeadSHA: head, Base: "main"}
|
||||
if merged {
|
||||
p.Merged, p.MergeCommit = true, strings.Repeat("e", 40)
|
||||
}
|
||||
w.forge.pulls[repo+"#4"] = p
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
||||
told := hearConditions(w)
|
||||
moves := len(w.source.moves)
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
want := Closed
|
||||
if merged {
|
||||
want = Merged
|
||||
}
|
||||
if st := w.state(id); st != want {
|
||||
t.Fatalf("merged %v: the delivery is %s, wanted %s", merged, st, want)
|
||||
}
|
||||
if c := told.last(id); c != nil {
|
||||
t.Fatalf("merged %v: a condition was said for it: %+v", merged, c)
|
||||
}
|
||||
if len(w.source.moves) != moves {
|
||||
t.Fatalf("merged %v: source.main moved for a merge learnt by its absence: %+v", merged, w.source.moves)
|
||||
}
|
||||
}
|
||||
}},
|
||||
// Review round 2: a merge learnt after the fact moves no other delivery's trunk (an approved group of the same
|
||||
// repository keeps its trunk, its state and its composed check), takes the forge's time, and a catch-up after it
|
||||
// does not move source.main.
|
||||
{"a merge learnt late moves no trunk and no source", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
allGreen(w)
|
||||
w.merges.manual = true
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
const lateHead, lateMerge = "9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a", "8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b8b"
|
||||
w.h.PullUpdated(pr(gCat, 9, lateHead, "fix/old", "modules/app/x.go"))
|
||||
w.h.Checked(judged(gCat, 9, lateHead, "pass", gTrunk0))
|
||||
w.settleAll()
|
||||
late := IDOf(gCat, lateHead)
|
||||
trunkOf := func() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, id := range []string{IDOf(gCat, gHk), IDOf(gCtl, gHc)} {
|
||||
out[id] = w.delivery(id).TrunkNow + " " + string(w.delivery(id).State)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before, moves := trunkOf(), len(w.source.moves)
|
||||
w.forge.pulls[gCat+"#9"] = ForgePull{Number: 9, State: "closed", Merged: true, MergeCommit: lateMerge,
|
||||
MergedAt: "2026-10-01T09:00:00Z", HeadSHA: lateHead, Base: "main"}
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "mesh-catalog", Number: 2, Base: "main", HeadSHA: gHk},
|
||||
{Owner: "novox", Repo: "mesh-controller", Number: 1, Base: "main", HeadSHA: gHc}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
d := w.delivery(late)
|
||||
if d.State != Merged || !d.MergedAt.Equal(time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)) || !d.LearntLate {
|
||||
t.Fatalf("the late merge is %s at %v (learnt late %v)", d.State, d.MergedAt, d.LearntLate)
|
||||
}
|
||||
if after := trunkOf(); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("the group's trunk or state moved: %v, was %v", after, before)
|
||||
}
|
||||
if g := w.h.groups["feat/g"]; g.Check == nil || g.Check.OutOfDate != "" {
|
||||
t.Fatalf("the group's composed check went out of date: %+v", g.Check)
|
||||
}
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
if len(w.source.moves) != moves {
|
||||
t.Fatalf("source.main moved for a merge learnt late: %+v", w.source.moves[moves:])
|
||||
}
|
||||
}},
|
||||
// mesh-delivery#51 (2026-10-25 21:43): the open list was read seconds after the bridge merged it and before the forge's
|
||||
// merged event, so it was learnt late, moved no trunk, and its group's finish left it out of source.main. A merge
|
||||
// learnt within replayLate of its landing is taken as heard as it happened; one a gate before the fix took late is
|
||||
// written at the catch-up of the start.
|
||||
{"a merge learnt from the open list seconds after it landed moves source.main", func(t *testing.T) {
|
||||
const repo, head, merge = "novox/mesh-delivery", "4628ba26b41a4628ba26b41a4628ba26b41a4628", "db53ec975f01db53ec975f01db53ec975f01db53"
|
||||
for _, oldGate := range []bool{false, true} {
|
||||
w := newWorld(t)
|
||||
w.merges.manual = true
|
||||
w.h.PullUpdated(pr(repo, 51, head, "fix/1945", "modules/a/a.go"))
|
||||
w.h.Checked(judgedAt(repo, 51, head, "pass", "t1t1t1t1t1t1"))
|
||||
w.settleAll()
|
||||
w.source.main = &sdk.Source{V: sdk.Version, Group: "before", Heads: map[string]string{repo: "73be3df28cd4"}}
|
||||
w.forge.pulls[repo+"#51"] = ForgePull{Number: 51, State: "closed", Merged: true, MergeCommit: merge,
|
||||
MergedAt: w.h.Now().Add(-3 * time.Second).UTC().Format(time.RFC3339), HeadSHA: head, Base: "main"}
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "other", Number: 1, Base: "main", HeadSHA: "abab"}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
if oldGate { // what the gate before the fix left
|
||||
w.h.mu.Lock()
|
||||
kept := w.h.deliveries[IDOf(repo, head)]
|
||||
kept.LearntLate, kept.InSource, kept.Ready.SourceMoved = true, true, true
|
||||
w.h.mu.Unlock()
|
||||
w.source.main.Heads[repo] = "73be3df28cd4"
|
||||
w.h.SourceCatchUp()
|
||||
w.settleAll()
|
||||
}
|
||||
d := w.delivery(IDOf(repo, head))
|
||||
if d.State != Merged || (!oldGate && d.LearntLate) || w.source.main.Heads[repo] != merge {
|
||||
t.Fatalf("old gate %v: %s, learnt late %v, source.main %v", oldGate, d.State, d.LearntLate, w.source.main.Heads)
|
||||
}
|
||||
if heads, _ := w.h.SourceMain()["heads"].(map[string]string); heads[repo] != merge {
|
||||
t.Fatalf("delivery_status's source-main says %v", w.h.SourceMain())
|
||||
}
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 2 (1275/AC6): an out-of-date refusal at t0 with no other event is said urgent at t0+10
|
||||
// minutes: the gate asks itself for a look then.
|
||||
{"an out-of-date refusal turns urgent on the clock alone", func(t *testing.T) {
|
||||
w := approvedGroup(t)
|
||||
told := hearConditions(w)
|
||||
allGreen(w)
|
||||
w.forge.refuse = errAskedAgain
|
||||
w.op.answer(w.op.open()[0], OptionApprove)
|
||||
w.settleAll()
|
||||
t0 := w.h.Now()
|
||||
w.h.wanted.mu.Lock()
|
||||
at := w.h.wanted.at
|
||||
w.h.wanted.mu.Unlock()
|
||||
if at.IsZero() || at.Sub(t0) > outOfDateFor {
|
||||
t.Fatalf("no look is wanted by t0+%s: %v", outOfDateFor, at)
|
||||
}
|
||||
for i := 0; i < 5 && w.h.Now().Before(t0.Add(outOfDateFor)); i++ { // the timer fires a look, and nothing else happens
|
||||
w.h.wanted.mu.Lock()
|
||||
at = w.h.wanted.at
|
||||
w.h.wanted.mu.Unlock()
|
||||
if at.IsZero() {
|
||||
break
|
||||
}
|
||||
w.later(at.Sub(w.h.Now()))
|
||||
w.h.React()
|
||||
}
|
||||
c := told.last(IDOf(gCat, gHk))
|
||||
if c == nil || c.State != asks.StateRaised || c.Severity != asks.SeverityUrgent {
|
||||
t.Fatalf("at t0+%s with no event: %+v", w.h.Now().Sub(t0), c)
|
||||
}
|
||||
}},
|
||||
// Review round 1, blocker 3: a gate that restarts while a head still waits on the forge does not clear its
|
||||
// condition (the head's first sight is in memory alone, so its bound runs again), and does not say it again.
|
||||
{"a restart keeps the condition of a head still waiting", func(t *testing.T) {
|
||||
w := newWorld(t)
|
||||
told := hearConditions(w)
|
||||
const head = "bfe82315f42c8f4509d0598c32569a202d5eada6"
|
||||
id := IDOf("novox/hq", head)
|
||||
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}}}
|
||||
w.h.wantForge()
|
||||
w.settleAll()
|
||||
w.later(UncheckedAfter + time.Minute)
|
||||
w.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateRaised {
|
||||
t.Fatalf("the unchecked head is not raised: %+v", c)
|
||||
}
|
||||
before := len(told.told)
|
||||
again := newWorldOver(t, w.store, w.forge, w.h.Now()) // the gate restarts
|
||||
again.h.Say, again.h.Conditions = told.say, told
|
||||
again.h.wantForge()
|
||||
again.settleAll()
|
||||
again.later(time.Minute)
|
||||
again.settleAll()
|
||||
if len(told.told) != before {
|
||||
t.Fatalf("the restart said something of a head still waiting: %+v", told.told[before:])
|
||||
}
|
||||
w.forge.open = nil // it closes
|
||||
again.h.wantForge()
|
||||
again.settleAll()
|
||||
if c := told.last(id); c == nil || c.State != asks.StateCleared {
|
||||
t.Fatalf("closed, and its condition is not cleared: %+v", c)
|
||||
}
|
||||
}},
|
||||
// The kind behind the day's fault: a decision on a group's state named a word GroupState no longer says. Every word
|
||||
// it says is one groupStatusState knows, and the merge goes on exactly in ready and merging.
|
||||
{"every word of a group's state is decided on", func(t *testing.T) {
|
||||
for _, word := range groupWords {
|
||||
if groupStatusState(word) == "pending" && word != GroupChecking {
|
||||
t.Errorf("groupStatusState does not know %q", word)
|
||||
}
|
||||
if may := groupMayMerge(word); may != (word == GroupReady || word == GroupMerging) {
|
||||
t.Errorf("groupMayMerge(%q) = %v", word, may)
|
||||
}
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, c.run)
|
||||
}
|
||||
}
|
||||
|
||||
// allGreenFor makes both merge checks a success at every member's head.
|
||||
func allGreenFor(w *world, ms ...member) {
|
||||
for _, m := range ms {
|
||||
setChecks(w, m.repo, m.head, "success", "success")
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeMerged is the forge bridge's word that it merged a member.
|
||||
func bridgeMerged(w *world, repository string, number int, head, merge string) {
|
||||
w.merges.say(sdk.Forge{Kind: sdk.ForgeMerged, Repository: repository, Number: number, Head: head, Merge: merge, Root: "root",
|
||||
Trees: map[string]string{"app": "tree"}})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "git.novox.be/novox/mesh-sdk/go/announce"
|
||||
// issue 1790): the module says it itself, from its code, before it serves, and a reader is granted what it said
|
||||
// (clause 3). A state is readable when another module's manifest reads it (keeps_test.go names the readers). keeps_test.go holds the list to the
|
||||
// manifest's `state`, and keeps_integration_test.go that the module announces it on a bus.
|
||||
var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}}
|
||||
var Kept = []announce.State{{Name: "asks", Readable: true}, {Name: "conditions"}, {Name: "deliveries"}, {Name: "groups"}, {Name: "merges", Readable: true}, {Name: "owed", Readable: true}, {Name: "pulls", Readable: true}, {Name: "source", Readable: true}}
|
||||
|
||||
// announceKept says Kept through keeps, which is stdio.Keeps.
|
||||
func announceKept(keeps func(...announce.State) error) error { return keeps(Kept...) }
|
||||
|
||||
@@ -67,6 +67,9 @@ func main() {
|
||||
Source: kvSource{},
|
||||
Owed: kvOwed{},
|
||||
Pulls: kvPulls{},
|
||||
// The gate says what stalled to the operator itself (issue 1275, ADR 0437 §9).
|
||||
Say: sayOnSeat,
|
||||
Conditions: kvConditions{},
|
||||
}
|
||||
h.init()
|
||||
l := &listening{now: "not yet: starting"}
|
||||
@@ -357,8 +360,13 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
{Name: "delivery_status",
|
||||
Description: "Whether this owner works: how many deliveries in each state, the groups, what is owed to " +
|
||||
"the forge and the bus and not yet done, how many owed effects were tried since it started (done, failed " +
|
||||
"and tried again, given up) and when the last was, whether its events arrive, and the last refusals.",
|
||||
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
|
||||
"and tried again, given up) and when the last was, whether its events arrive, the last refusals, and " +
|
||||
"source.main as the gate's bucket source holds it (each repository's trunk head the checker judges on).",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
out := h.Status(l.get())
|
||||
out["source-main"] = h.SourceMain()
|
||||
return out, nil
|
||||
}},
|
||||
}
|
||||
// A verb that changes a delivery asks for a look at what it makes possible (react.go).
|
||||
for i := range list {
|
||||
|
||||
@@ -91,9 +91,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
if !reflect.DeepEqual(m.Emits, []string{"transition", "group", "merged"}) {
|
||||
t.Fatalf("emits %v", m.Emits)
|
||||
}
|
||||
// Seven buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3.
|
||||
// Eight buckets, and no `asked` (the teller asks, ADR 0437 §6): ADR 0450 §3, and the conditions it raised (issue 1275).
|
||||
buckets := m.buckets()
|
||||
if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source"}) {
|
||||
if !reflect.DeepEqual(buckets, []string{"deliveries", "groups", "pulls", "asks", "merges", "owed", "source", "conditions"}) {
|
||||
t.Fatalf("state %v", buckets)
|
||||
}
|
||||
// What it reads: the teller's answers and the checker's verdicts (issue 1302). The carry from today's module's buckets, the
|
||||
@@ -102,10 +102,10 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
|
||||
t.Fatalf("reads %v", m.Reads)
|
||||
}
|
||||
// It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It
|
||||
// uses no operator-channel: the teller asks (ADR 0437 §6).
|
||||
// uses operator-channel only to say its conditions (ADR 0437 §9, issue 1275): the teller asks (ADR 0437 §6).
|
||||
want := []string{"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls"}
|
||||
if len(m.Uses) != 0 {
|
||||
t.Fatalf("uses %v: the teller asks, not the gate", m.Uses)
|
||||
if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) {
|
||||
t.Fatalf("uses %v: operator-channel, for its conditions alone", m.Uses)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Invokes, want) {
|
||||
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
|
||||
|
||||
@@ -105,6 +105,26 @@ func (kvSource) Move(update func(prev *sdk.Source) (sdk.Source, bool)) error {
|
||||
return fmt.Errorf("source.main was written by another writer first %d times: %w", changeAttempts, stdio.ErrChanged)
|
||||
}
|
||||
|
||||
// SourceMain is source.main as the bucket source holds it, read and never written: its group and each repository's trunk
|
||||
// head, or why it cannot be read. Outside the lock.
|
||||
func (h *Holder) SourceMain() map[string]any {
|
||||
if h.Source == nil {
|
||||
return map[string]any{"error": "no source bucket"}
|
||||
}
|
||||
var held *sdk.Source
|
||||
err := h.Source.Move(func(prev *sdk.Source) (sdk.Source, bool) {
|
||||
held = prev
|
||||
return sdk.Source{}, false
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
return map[string]any{"error": err.Error()}
|
||||
case held == nil:
|
||||
return map[string]any{"heads": map[string]string{}, "says": "source.main is not written yet"}
|
||||
}
|
||||
return map[string]any{"group": held.Group, "heads": held.Heads}
|
||||
}
|
||||
|
||||
// approvedOf are the heads an Approve covers as a merge carries them: `<repository>@<number>` to the full head.
|
||||
func (h *Holder) approvedOf(heads map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
@@ -356,6 +376,9 @@ func (h *Holder) settleMerges() []func() {
|
||||
var members []string
|
||||
for hid := range r.Heads {
|
||||
m := h.deliveries[hid]
|
||||
if m.LearntLate {
|
||||
continue // learnt after the fact: never written to source.main (settleGone)
|
||||
}
|
||||
commit := m.MergedAs
|
||||
if m.Ready != nil && m.Ready.MergeCommit != "" {
|
||||
commit = m.Ready.MergeCommit
|
||||
@@ -363,6 +386,9 @@ func (h *Holder) settleMerges() []func() {
|
||||
heads[m.Repository] = commit
|
||||
members = append(members, hid)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
continue // every member learnt after the fact: nothing to write
|
||||
}
|
||||
sort.Strings(members)
|
||||
last := h.deliveries[members[len(members)-1]]
|
||||
out = append(out, func() { h.finish(name, key, approved, last.ID, heads, members) })
|
||||
@@ -428,6 +454,18 @@ func gateMerges(d *Delivery) bool {
|
||||
return d.Ready != nil && d.Ready.Approved != nil && d.Ready.AskID != "" && len(d.Ready.Heads) > 0
|
||||
}
|
||||
|
||||
// leftOutOfForgeSource is whether settleForgeMerges never writes a merge to source.main: one the gate handed on an Approve
|
||||
// (finish writes it), and one learnt after the fact, long past or with no time the forge said. One learnt late within
|
||||
// replayLate of its merge, as a gate before the fix took mesh-delivery#51 (2026-10-25, its group's finish left it out and
|
||||
// source.main kept the trunk before it), is weighed like any merge heard from the forge at a catch-up, and never moves
|
||||
// source.main back (olderHead).
|
||||
func (d *Delivery) leftOutOfForgeSource() bool {
|
||||
if d.LearntLate {
|
||||
return d.MergedAt.IsZero() || d.Since.Sub(d.MergedAt) >= replayLate
|
||||
}
|
||||
return gateMerges(d)
|
||||
}
|
||||
|
||||
// settleForgeMerges moves source.main for each merge into the trunk heard only from the forge (issue 888): a pull request
|
||||
// merged at the forge directly, not handed by the gate on an Approve. Every other delivery's trunk moved with it (trunkMoved),
|
||||
// and the checker judges on the trunk source.main names, so source.main moves too. A merge replayed as history, or older than
|
||||
@@ -441,7 +479,7 @@ func (h *Holder) settleForgeMerges() []func() {
|
||||
again = h.newestInSource()
|
||||
}
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs == "" || (d.InSource && !again[d]) || gateMerges(d) {
|
||||
if d.MergedAs == "" || (d.InSource && !again[d]) || d.leftOutOfForgeSource() {
|
||||
continue
|
||||
}
|
||||
if d.Base != MergeBranch || d.replayed() || h.newerMergeOf(d) {
|
||||
@@ -558,7 +596,7 @@ func (h *Holder) SourceCatchUp() {
|
||||
func (h *Holder) newestInSource() map[*Delivery]bool {
|
||||
newest := map[string]*Delivery{}
|
||||
for _, d := range h.deliveries {
|
||||
if d.MergedAs == "" || !d.InSource || gateMerges(d) || d.Base != MergeBranch || d.replayed() {
|
||||
if d.MergedAs == "" || !d.InSource || d.leftOutOfForgeSource() || d.Base != MergeBranch || d.replayed() {
|
||||
continue
|
||||
}
|
||||
if o := newest[d.Repository]; o == nil || mergedWhen(d).After(mergedWhen(o)) {
|
||||
|
||||
@@ -82,8 +82,11 @@ type Delivery struct {
|
||||
MergedAt time.Time `json:"merged_at"`
|
||||
// InSource says the merge was looked at for source.main: written there, or left as older than the trunk's head (issue 888).
|
||||
// A merge the gate handed to the bridge on an Approve is moved there by finish instead (merges.go).
|
||||
InSource bool `json:"in_source,omitempty"`
|
||||
HeldWhy string `json:"held_why,omitempty"`
|
||||
InSource bool `json:"in_source,omitempty"`
|
||||
// LearntLate says its merge was learnt after the fact, from its absence among the forge's open pull requests
|
||||
// (settleGone, issue 1275): it moved no trunk here, and it never moves source.main, not even at a catch-up.
|
||||
LearntLate bool `json:"learnt_late,omitempty"`
|
||||
HeldWhy string `json:"held_why,omitempty"`
|
||||
// FromForge is a delivery made from the forge's word at its merge: its head was never heard by this owner.
|
||||
FromForge bool `json:"from_forge,omitempty"`
|
||||
|
||||
|
||||
@@ -155,6 +155,13 @@ func (h *Holder) takeCheckStatus(repository, sha, state, desc string) {
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
}
|
||||
if d.State == Ready && !h.checkGates() {
|
||||
// The check gates no merge (check-gates-merge false): its verdict is shown on the delivery, and moves nothing.
|
||||
d.Check = v
|
||||
h.Logf("[mesh-delivery] %s: its mesh/check is %s — %s (shown; the check gates no merge)", d.ID, state, oneLine(clip(desc, 120)))
|
||||
h.keep(d)
|
||||
return
|
||||
}
|
||||
if d.State != Proposed {
|
||||
h.keep(d)
|
||||
return // past its verdict: the merge reads the forge's status itself
|
||||
|
||||
@@ -45,7 +45,9 @@ type ForgePull struct {
|
||||
Head string `json:"head,omitempty"`
|
||||
HeadSHA string `json:"head_sha,omitempty"`
|
||||
MergeCommit string `json:"merge_commit_sha,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
// MergedAt is when it merged, as the forge says it (RFC 3339); empty when not merged or not said.
|
||||
MergedAt string `json:"merged_at,omitempty"`
|
||||
HTMLURL string `json:"html_url,omitempty"`
|
||||
}
|
||||
|
||||
// ForgeStatus is one status of a commit, the newest of its context.
|
||||
|
||||
@@ -57,7 +57,7 @@ func (h *Holder) Work() {
|
||||
|
||||
// React takes every step the facts allow, after an event that can change what they are: the forge's open pull requests
|
||||
// read when a check may have answered, the steps that need another's word (look), what is owed (Flush), and then
|
||||
// the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
|
||||
// the conditions of what stalled (conditions.go), and then the time the next look is wanted at, if any step waits on the clock. Never concurrent with itself.
|
||||
func (h *Holder) React() {
|
||||
h.reactMu.Lock()
|
||||
defer h.reactMu.Unlock()
|
||||
@@ -75,6 +75,7 @@ func (h *Holder) React() {
|
||||
}
|
||||
h.look()
|
||||
h.Flush()
|
||||
h.sayConditions()
|
||||
h.arm()
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
// - a group member only once the group's composed check passed for its members' heads as they are;
|
||||
// - a push ends the Approve and says so once on the pull request (ADR 0421 §6); the new head is asked on its own;
|
||||
// - a check that failed merges nothing: it is said once on the pull request, and as a stalled line, which the
|
||||
// controller raises as the condition `delivery.<id>.stalled`. So is a merge the tool refused for an hour.
|
||||
// gate says to the operator as a condition (conditions.go). So is a merge the tool refused for an hour.
|
||||
//
|
||||
// The review record is read and put in the ask; it is never a gate (ADR 0421 §5). What this owner does is kept on the
|
||||
// delivery (Ready), said in its log, and the merge's transitions name the Approve as their warrant.
|
||||
@@ -157,6 +157,7 @@ func (h *Holder) runOf(d *Delivery, now time.Time) *ReadyRun {
|
||||
func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
var asks []func()
|
||||
h.endStaleAsks()
|
||||
gates := h.checkGates()
|
||||
ids := make([]string, 0, len(h.deliveries))
|
||||
for id := range h.deliveries {
|
||||
ids = append(ids, id)
|
||||
@@ -167,6 +168,9 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
if d.Number == 0 || d.State.Final() || d.MergedAs != "" {
|
||||
continue // not of a pull request, or past its merge
|
||||
}
|
||||
if !gates {
|
||||
h.ungate(d, now)
|
||||
}
|
||||
if d.Ready == nil && d.State == Ready {
|
||||
h.runOf(d, now)
|
||||
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
|
||||
@@ -271,6 +275,30 @@ func (h *Holder) readyAsks(now time.Time) []func() {
|
||||
return asks
|
||||
}
|
||||
|
||||
// ungate stands a delivery where it is while mesh/check gates no merge (check-gates-merge false): a proposed one is ready
|
||||
// with no verdict, and a rejected one, refused on a verdict before the setting changed, is proposed again and so ready. Its
|
||||
// verdict stays on it, shown. Under the lock.
|
||||
func (h *Holder) ungate(d *Delivery, now time.Time) {
|
||||
f := h.factsAt(now)
|
||||
if d.State == Rejected {
|
||||
f.By, f.Why = byOwner, "the check gates no merge (check-gates-merge false): its verdict is shown, not waited on"
|
||||
t, err := Apply(d, EvRecheck, true, f)
|
||||
if err != nil {
|
||||
h.refuse(err)
|
||||
return
|
||||
}
|
||||
if d.Ready != nil {
|
||||
d.Ready.Failed = ""
|
||||
}
|
||||
h.moved(d, []Transition{t})
|
||||
f.By, f.Why = "", ""
|
||||
}
|
||||
if d.State == Proposed {
|
||||
h.moved(d, settle(d, f))
|
||||
h.closeGroupOf(d)
|
||||
}
|
||||
}
|
||||
|
||||
// sayAfterLine tells a pull request, once, that its description holds an `after: <repository>` line, which is not read
|
||||
// (novox/hq ADR 0419 §2): the order of a delivery comes from the module graph alone, and no refusal asks for the line.
|
||||
// Once per pull request, not per head. Under the lock; the comment is said outside it.
|
||||
@@ -337,6 +365,9 @@ func (r *ReadyRun) noteRefusal(d *Delivery, answer string) {
|
||||
// stays through a trunk move, ADR 0421 §3) and its check against the trunk's head is not on its way; one without an
|
||||
// Approve waits for that check first.
|
||||
func (h *Holder) readyWaits(d *Delivery) string {
|
||||
if !h.checkGates() {
|
||||
return h.ungatedWaits(d)
|
||||
}
|
||||
if d.judgedByCheck() && d.State == Proposed && d.outOfDate() != "" && h.covering(d) == nil {
|
||||
return "its mesh/check is " + d.outOfDate() + ": the checker checks it again against the trunk's head"
|
||||
}
|
||||
@@ -369,6 +400,27 @@ func (h *Holder) readyWaits(d *Delivery) string {
|
||||
return h.groupTurnToMerge(d)
|
||||
}
|
||||
|
||||
// ungatedWaits is readyWaits while mesh/check gates no merge (check-gates-merge false): a ready delivery waits only for its
|
||||
// review at its head, and a group member for every member to be ready; no verdict, composed check or trunk is waited on.
|
||||
func (h *Holder) ungatedWaits(d *Delivery) string {
|
||||
if d.State != Ready {
|
||||
return "it is " + string(d.State) + ", not ready yet"
|
||||
}
|
||||
if h.covering(d) == nil {
|
||||
if w := h.reviewWaits(d); w != "" {
|
||||
return w
|
||||
}
|
||||
}
|
||||
if g := h.groups[d.Group]; d.Group != "" && g != nil {
|
||||
for _, m := range h.membersOf(g) {
|
||||
if m.State != Ready && m.State != Merged {
|
||||
return "its group " + g.ID + " waits for " + m.ID + ", which is " + string(m.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
||||
const waitsForReview = "waits for a review at "
|
||||
|
||||
@@ -401,13 +453,14 @@ func (d *Delivery) holdsForReview() bool {
|
||||
// groupTurnToMerge is why a group member may not merge yet; empty when it may: the group's composed check passed for
|
||||
// its members' heads as they are and is not out of date. No member waits for another's merge or delivery (novox/hq
|
||||
// ADR 0418 §4): once the composed check passed and the Approve covers the heads, every member is merged in one pass.
|
||||
// A group part way through that pass is merging, and its next member's turn is then (issue 1275).
|
||||
func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
g := h.groups[d.Group]
|
||||
if g == nil {
|
||||
return ""
|
||||
}
|
||||
members := h.membersOf(g)
|
||||
if state, why := GroupState(g, members); state != "ready" && state != "delivering" {
|
||||
if state, why := GroupState(g, members); !groupMayMerge(state) {
|
||||
return fmt.Sprintf("its group %s is %s: %s", g.ID, state, why)
|
||||
}
|
||||
if c := g.Check; c == nil || (c.Verdict != "pass" && c.Verdict != "warning") || c.OutOfDate != "" ||
|
||||
@@ -417,6 +470,16 @@ func (h *Holder) groupTurnToMerge(d *Delivery) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupMayMerge is whether a group in this state hands its next member to the forge bridge: one ready, and one merging (a
|
||||
// member merged, the pass goes on). Every other word holds the pass.
|
||||
func groupMayMerge(state string) bool {
|
||||
switch state {
|
||||
case GroupReady, GroupMerging:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sayOn owes a comment on a delivery's pull request: an owed act numbered among the comments on its head, written as a key
|
||||
// with the delivery's other effects (owed.go). Under the lock; what it returns has nothing left to do.
|
||||
func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
@@ -427,7 +490,7 @@ func (h *Holder) sayOn(d *Delivery, body string) func() {
|
||||
}
|
||||
|
||||
// readyLines are the approved heads that will not merge: a check that failed at once, a merge refused past its bound;
|
||||
// as stalled lines, which the controller raises as conditions. Under the lock.
|
||||
// as stalled lines, which the gate says as conditions (conditions.go). Under the lock.
|
||||
func (h *Holder) readyLines(now time.Time) []StalledLine {
|
||||
var out []StalledLine
|
||||
for _, d := range h.deliveries {
|
||||
|
||||
@@ -64,6 +64,43 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
if len(w.forge.merges) != 1 {
|
||||
t.Fatalf("merged again: %v", w.forge.merges)
|
||||
}
|
||||
|
||||
// check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another
|
||||
// merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's
|
||||
// status and show say the mode.
|
||||
u := newWorld(t)
|
||||
ungated(u)
|
||||
u.op.auto = false
|
||||
const other = "3333333333333333333333333333333333333333"
|
||||
u.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
|
||||
u.h.PullUpdated(pr("novox/app", 8, other, "feat/y", "src/b.go"))
|
||||
u.settleAll()
|
||||
if u.op.count() != 2 || u.state(IDOf("novox/app", readyHead)) != Ready {
|
||||
t.Fatalf("ungated, two reviewed pull requests with no verdict: %d asks, %s", u.op.count(), u.state(IDOf("novox/app", readyHead)))
|
||||
}
|
||||
if gates, _ := u.h.Status("listening")["check-gates-merge"].(bool); gates {
|
||||
t.Fatalf("delivery_status does not say the check gates no merge: %v", u.h.Status("listening"))
|
||||
}
|
||||
shown, _ := u.h.Show(IDOf("novox/app", other))
|
||||
if gates, _ := shown.(map[string]any)["check-gates-merge"].(bool); gates {
|
||||
t.Fatalf("show does not say the check gates no merge: %v", shown)
|
||||
}
|
||||
u.h.Checked(judgedAt("novox/app", 8, other, "fail", "t1t1t1t1t1t1"))
|
||||
u.h.PullMerged(merged("novox/app", 7, readyHead, "m7m7m7m7m7m7"))
|
||||
u.settleAll()
|
||||
d = u.delivery(IDOf("novox/app", other))
|
||||
if d.State != Ready || d.Check == nil || d.Check.Passes() {
|
||||
t.Fatalf("ungated, after a failed verdict and a trunk's move: %s, verdict %+v", d.State, d.Check)
|
||||
}
|
||||
for _, a := range u.op.open() {
|
||||
if strings.Contains(a, "feat/y") {
|
||||
u.op.answer(a, OptionApprove)
|
||||
}
|
||||
}
|
||||
u.settleAll()
|
||||
if len(u.forge.merges) != 1 || u.forge.merges[0] != "novox/app#8@"+other {
|
||||
t.Fatalf("ungated, the approved head is merged %v; wanted once, with no verdict waited on", u.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
// 1144/AC3: a refused merge is not asked again by any timer; only an event (here, mergeAgainNow called directly, as a
|
||||
@@ -207,6 +244,22 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
if w.op.count() != 1 {
|
||||
t.Fatalf("a second Approve was asked: %d", w.op.count())
|
||||
}
|
||||
|
||||
// check-gates-merge false: the group is asked once with no verdict and no composed check, and merges whole in one pass.
|
||||
u := newWorld(t)
|
||||
ungated(u)
|
||||
u.op.auto = false
|
||||
u.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
u.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
u.settleAll()
|
||||
if u.op.count() != 1 {
|
||||
t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
|
||||
}
|
||||
u.op.answer(u.op.open()[0], OptionApprove)
|
||||
u.settleAll()
|
||||
if len(u.forge.merges) != 2 {
|
||||
t.Fatalf("ungated, both members merge in one pass: %v", u.forge.merges)
|
||||
}
|
||||
}
|
||||
|
||||
// **A merge into another branch than main is its author's** (novox/hq ADR 0398 §4, issue 948): a green pull request
|
||||
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
type Settings struct {
|
||||
RefusalFloorMinutes *int `json:"refusal-stall-floor-minutes"`
|
||||
AuthorBoundMinutes *int `json:"author-new-head-minutes"`
|
||||
// CheckGatesMerge is whether mesh/check gates the merge (the default, true). False (the operator, 2026-10-25 22:56:
|
||||
// "run the check locally only, but not as a gate, it loses us too much time"): a reviewed pull request is ready and its
|
||||
// Approve asked without a verdict, a trunk's move puts nothing out of date, and a verdict is shown, never waited on.
|
||||
CheckGatesMerge *bool `json:"check-gates-merge"`
|
||||
}
|
||||
|
||||
// readSettings reads the settings file; a value that is not a whole number of minutes, one or more, is refused.
|
||||
@@ -55,9 +59,30 @@ func readSettings(path string) (Settings, error) {
|
||||
return Settings{}, fmt.Errorf("%s is not a number", name)
|
||||
}
|
||||
}
|
||||
switch v := loose["check-gates-merge"].(type) {
|
||||
case nil:
|
||||
case bool:
|
||||
s.CheckGatesMerge = &v
|
||||
default:
|
||||
return Settings{}, errors.New("check-gates-merge is not true or false")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// checkGates is whether mesh/check gates the merge: true unless the setting check-gates-merge is false.
|
||||
func (h *Holder) checkGates() bool {
|
||||
if h.Settings == nil {
|
||||
return true
|
||||
}
|
||||
v := h.Settings().CheckGatesMerge
|
||||
return v == nil || *v
|
||||
}
|
||||
|
||||
// factsAt are the facts a settle reads at now: the moment, and whether the check gates the merge.
|
||||
func (h *Holder) factsAt(now time.Time) Facts {
|
||||
return Facts{Now: now, Ungated: !h.checkGates()}
|
||||
}
|
||||
|
||||
// minutesOr is a setting's minutes, or the default when it gives none.
|
||||
func minutesOr(n *int, def time.Duration) time.Duration {
|
||||
if n == nil {
|
||||
|
||||
@@ -84,6 +84,9 @@ type Facts struct {
|
||||
Now time.Time
|
||||
By string
|
||||
Why string
|
||||
// Ungated is the setting check-gates-merge false: mesh/check gates no merge, so a proposed delivery is accepted with no
|
||||
// verdict, none is refused for one, and a trunk's move puts none back to proposed.
|
||||
Ungated bool
|
||||
}
|
||||
|
||||
// Row is one transition the table holds.
|
||||
@@ -132,8 +135,11 @@ var Table = []Row{
|
||||
// cannot loop with `trunk moved`; a recheck or the merge tool's ask judges it again.
|
||||
{From: []State{Proposed}, Event: EvAccepted, To: Ready,
|
||||
Guard: "a verdict names this commit and the trunk's head as its trunk commit, the gate passed or warned, and " +
|
||||
"the repository's own check did not fail",
|
||||
"the repository's own check did not fail; or the check gates no merge (check-gates-merge false)",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if f.Ungated {
|
||||
return nil
|
||||
}
|
||||
if d.Check == nil {
|
||||
return errors.New("no verdict")
|
||||
}
|
||||
@@ -144,8 +150,11 @@ var Table = []Row{
|
||||
}},
|
||||
{From: []State{Proposed}, Event: EvRefused, To: Rejected,
|
||||
Guard: "a verdict names this commit and the trunk's head as its trunk commit, and the gate failed or could not run, " +
|
||||
"or the repository's own check failed",
|
||||
"or the repository's own check failed; never while the check gates no merge",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if f.Ungated {
|
||||
return errors.New("the check gates no merge (check-gates-merge false)")
|
||||
}
|
||||
if d.Check == nil {
|
||||
return errors.New("no verdict")
|
||||
}
|
||||
@@ -157,8 +166,11 @@ var Table = []Row{
|
||||
// **A ready delivery whose verdict went out of date goes back to proposed** (novox/hq ADR 0323 §7): another
|
||||
// merge moved its trunk. A rejected one stays rejected, its status on the forge saying the verdict is out of date.
|
||||
{From: []State{Ready}, Event: EvTrunkMoved, To: Proposed,
|
||||
Guard: "its verdict names a trunk commit that is not its trunk's head",
|
||||
Guard: "its verdict names a trunk commit that is not its trunk's head, and the check gates the merge",
|
||||
Holds: func(d *Delivery, f Facts) error {
|
||||
if f.Ungated {
|
||||
return errors.New("the check gates no merge (check-gates-merge false)")
|
||||
}
|
||||
return unless(d.MergedAs == "" && d.outOfDate() != "", "not merged, and its verdict is not out of date")
|
||||
},
|
||||
Says: func(d *Delivery) string { return d.outOfDate() }},
|
||||
|
||||
@@ -155,6 +155,22 @@ func TestTheTableKeepsItsRules(t *testing.T) {
|
||||
if _, err := Apply(d0, EvReask, true, Facts{Now: time.Now(), By: byH2, Why: "stalled"}); err == nil {
|
||||
t.Fatal("a check re-asked once was re-asked again")
|
||||
}
|
||||
// While the check gates no merge (check-gates-merge false): a proposed delivery is accepted with no verdict or a failed
|
||||
// one, none is refused, and a trunk's move puts no ready one back to proposed.
|
||||
off := Facts{Now: time.Now(), Ungated: true}
|
||||
for _, check := range []*Verdict{nil, {Gate: "fail"}} {
|
||||
p := &Delivery{ID: "novox/app@c0", State: Proposed, Number: 1, Check: check}
|
||||
if _, err := Apply(p, EvRefused, false, off); err == nil {
|
||||
t.Fatalf("ungated, a verdict %+v refused the delivery", check)
|
||||
}
|
||||
if _, err := Apply(p, EvAccepted, false, off); err != nil || p.State != Ready {
|
||||
t.Fatalf("ungated, a delivery with the verdict %+v is %s: %v", check, p.State, err)
|
||||
}
|
||||
}
|
||||
stale := &Delivery{ID: "novox/app@c0", State: Ready, Check: &Verdict{Gate: "pass", Trunk: "d28ca95bbbbb"}, TrunkNow: "690966dccccc"}
|
||||
if _, err := Apply(stale, EvTrunkMoved, false, off); err == nil {
|
||||
t.Fatal("ungated, a trunk's move put a ready delivery back to proposed")
|
||||
}
|
||||
// A ready delivery off the trunk is refused its merge, whatever else is true of it.
|
||||
d := &Delivery{ID: "novox/app@c0", State: Ready}
|
||||
if _, err := Apply(d, EvMerged, false, Facts{Now: time.Now()}); err == nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -16,7 +18,7 @@ import (
|
||||
// (novox/hq ADR 0406 §1, issue 975) — and once more at a head's bound, and one whose
|
||||
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
|
||||
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
|
||||
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
|
||||
// gate says as a condition (conditions.go) like any other delivery past its bound. The delivery domain's own
|
||||
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
|
||||
//
|
||||
// A head whose check started and never answered is said the same way (novox/hq issue 438). Counting any merge-check
|
||||
@@ -175,8 +177,24 @@ func (h *Holder) SeeOpenPulls() error {
|
||||
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
|
||||
return err
|
||||
}
|
||||
for _, gone := range h.seeOpen(open) {
|
||||
h.settleGone(gone)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPullsMost is the most open pull requests the forge's gitea_open_pulls answers: it reads 20 pages of 50 and says
|
||||
// nothing of the rest (mesh-catalog modules/gitea client.ts searchOpenPulls). A list that long may be cut, so what is
|
||||
// missing from it is not taken as closed.
|
||||
const openPullsMost = 20 * 50
|
||||
|
||||
// seeOpen keeps what the forge's open pull requests say, and answers the deliveries whose pull request is not among them:
|
||||
// open here, closed on the forge with no event heard (a close or merge while the gate was away, or an event lost). Only
|
||||
// from a complete list. Takes the lock.
|
||||
func (h *Holder) seeOpen(open []OpenPull) []*Delivery {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.forgeRead = true
|
||||
now := h.Now()
|
||||
seen := map[string]unchecked{}
|
||||
for _, p := range open {
|
||||
@@ -215,7 +233,55 @@ func (h *Holder) SeeOpenPulls() error {
|
||||
seen[id] = u
|
||||
}
|
||||
h.unchecked = seen
|
||||
return nil
|
||||
if len(open) >= openPullsMost {
|
||||
return nil
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, p := range open {
|
||||
listed[strings.ToLower(p.Owner+"/"+p.Repo)+"#"+strconv.Itoa(p.Number)] = true
|
||||
}
|
||||
var gone []*Delivery
|
||||
for _, d := range h.deliveries {
|
||||
if d.State.Final() || d.MergedAs != "" || d.Number <= 0 || listed[strings.ToLower(d.Repository)+"#"+strconv.Itoa(d.Number)] {
|
||||
continue
|
||||
}
|
||||
gone = append(gone, d)
|
||||
}
|
||||
sort.Slice(gone, func(i, j int) bool { return gone[i].ID < gone[j].ID })
|
||||
return gone
|
||||
}
|
||||
|
||||
// settleGone reads from the forge a pull request the open list does not hold, and takes its merge or its close as the
|
||||
// forge's event would have said it (issue 1275, review round 1: a pull request closed on 2026-10-17 was still ready here
|
||||
// eight days later). A merge learnt this way is past: its delivery is merged with the forge's time, moves no other
|
||||
// delivery's trunk, and never moves source.main (pullMerged, late); the trunk's merges heard as they happened move
|
||||
// those. A forge that cannot say changes nothing. Outside the lock.
|
||||
func (h *Holder) settleGone(d *Delivery) {
|
||||
h.mu.Lock()
|
||||
id, owner, repo, number, head, base, branch := d.ID, d.Owner(), d.Repo(), d.Number, d.Commit, d.Base, d.Branch
|
||||
h.mu.Unlock()
|
||||
// Read by its number alone: the forge reads the pull request's own head, which it keeps after the branch is gone.
|
||||
fc, err := h.Forge.Statuses(owner, repo, number, "", base)
|
||||
if err != nil || fc == nil || fc.Pull == nil {
|
||||
if err != nil {
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests, and the forge cannot say what became of it: %v", id, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
p := fc.Pull
|
||||
if p.State != "closed" || p.Number != number {
|
||||
return // open again, a state the forge did not say, or not this pull request: the next reading says
|
||||
}
|
||||
ev := PullEvent{Owner: owner, Repo: repo, Number: number, Base: base, Head: branch, HeadSHA: head, HTMLURL: p.HTMLURL}
|
||||
if p.Merged && p.MergeCommit != "" {
|
||||
ev.MergeCommit, ev.Merged, ev.MergedAt = p.MergeCommit, true, p.MergedAt
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it merged as %s at %s", id,
|
||||
shortOf(p.MergeCommit, 12), orUnknown(p.MergedAt))
|
||||
h.pullMerged(ev, true)
|
||||
return
|
||||
}
|
||||
h.Logf("[mesh-delivery] %s is not among the forge's open pull requests: the forge says it was closed unmerged", id)
|
||||
h.PullClosed(ev)
|
||||
}
|
||||
|
||||
// started is whether the head's check started, rather than was never asked: it has any merge check, or a delivery
|
||||
|
||||
@@ -75,7 +75,7 @@ func (h *Holder) Show(id string) (any, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if d := h.deliveries[id]; d != nil {
|
||||
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)}
|
||||
out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()}
|
||||
if g := h.groups[d.Group]; g != nil {
|
||||
state, why, _ := h.groupState(g, h.membersOf(g))
|
||||
out["group"] = map[string]any{"group": g, "state": state, "why": why}
|
||||
@@ -85,7 +85,7 @@ func (h *Holder) Show(id string) (any, error) {
|
||||
if g := h.groups[id]; g != nil {
|
||||
members := h.membersOf(g)
|
||||
state, why, _ := h.groupState(g, members)
|
||||
return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil
|
||||
return map[string]any{"group": g, "state": state, "why": why, "members": members, "check-gates-merge": h.checkGates()}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func (h *Holder) Groups(all bool) []GroupLine {
|
||||
continue
|
||||
}
|
||||
state, why, _ := h.groupState(g, members)
|
||||
if !all && (state == "merged" || state == "closed") {
|
||||
if !all && (state == GroupMerged || state == GroupClosed) {
|
||||
continue
|
||||
}
|
||||
l := GroupLine{ID: g.ID, State: state, Why: why, ComposedGate: true}
|
||||
@@ -185,7 +185,7 @@ type StalledLine struct {
|
||||
// NeedsNewHead is what a stalled line needs when its cure is its author's new head (ADR 0505 §5).
|
||||
const NeedsNewHead = "new-head"
|
||||
|
||||
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
|
||||
// Stalled is the `stalled` verb: what the gate says to the operator as conditions (conditions.go).
|
||||
func (h *Holder) Stalled() []StalledLine {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -286,7 +286,7 @@ func (h *Holder) Status(listening string) map[string]any {
|
||||
byState[string(s)] = n
|
||||
}
|
||||
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
|
||||
"events": listening, "started": h.started.Format(time.RFC3339)}
|
||||
"events": listening, "started": h.started.Format(time.RFC3339), "check-gates-merge": h.checkGates()}
|
||||
// The owed effects tried since this owner started (novox/hq issue 407): done, failed and to be tried again,
|
||||
// or given up, and when the last was tried. A count since the start, said with that start: two readings in
|
||||
// one run give a window's tries.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"refusal-stall-floor-minutes": 10,
|
||||
"author-new-head-minutes": 120
|
||||
"author-new-head-minutes": 120,
|
||||
"check-gates-merge": true
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"group",
|
||||
"merged"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
@@ -57,7 +60,8 @@
|
||||
{
|
||||
"name": "source",
|
||||
"history": 64
|
||||
}
|
||||
},
|
||||
"conditions"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-teller.answers",
|
||||
@@ -82,6 +86,12 @@
|
||||
"maximum": 10080,
|
||||
"trusted": false,
|
||||
"default": 120
|
||||
},
|
||||
"check-gates-merge": {
|
||||
"type": "boolean",
|
||||
"description": "Whether mesh/check gates the merge. True (the default): the operator's Approve is asked, and a merge handed, only on a passing verdict judged against the trunk's head, and a trunk's move puts a verdict out of date (ADR 0447, ADR 0505 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"; developers run the unit and integration levels locally before a pull request): a pull request reviewed at its head is ready and its Approve asked at once, with no verdict waited on; no merge is refused for a check judged on an older trunk; a verdict that comes is shown on the pull request and the delivery, never waited on; a group still merges all its members in one pass. Set it false together with the gitea module's check-gates-merge, which lets its merge tool merge an approved head without a passing mesh/check.",
|
||||
"trusted": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
@@ -99,7 +109,8 @@
|
||||
"mode": "0600",
|
||||
"takes-config": [
|
||||
"refusal-stall-floor-minutes",
|
||||
"author-new-head-minutes"
|
||||
"author-new-head-minutes",
|
||||
"check-gates-merge"
|
||||
],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json"
|
||||
|
||||
@@ -25,7 +25,8 @@ and the placement values its templates name — the client:
|
||||
the box crosses the bus. A secret is asked once per run, and again when the vault says `secret-changed` for it,
|
||||
so a rotation reaches its file without a new declaration. A secret with a `path` is written to that file alone,
|
||||
as an `owned` file. A secret the vault does not hold yet, or a vault that does not answer, is waited for; a
|
||||
refusal fails the module with the vault's reason and is asked again after ten minutes. A value is never written
|
||||
refusal fails the module with the vault's reason and is asked again after ten minutes, or at once when the
|
||||
module's assignment changes or the node-engine touches its write socket, either of which may end it. A value is never written
|
||||
to a bucket, a report or a log;
|
||||
- asks the vault the same way for each credential of what the module requires that its plan names
|
||||
(a `provisioning.provisions` entry with a `name`, issue 757; every entry names the provider serving that
|
||||
@@ -49,6 +50,10 @@ and the placement values its templates name — the client:
|
||||
- has the node-engine write each changed file through its local write socket (`/run/nox-mesh-host/fill.sock`,
|
||||
mesh-host `internal/filling`), which writes it as the declaration says (an `owned` file whole, a `seed` only when
|
||||
absent, a `managed` file's own keys or block) and restarts or reloads its program as the declaration says;
|
||||
- fills every module again when the node-engine makes its write socket (a new engine, issue 1165) or touches it, which
|
||||
it does after an apply whose declaration may let through a write it refused or failed (a path now named as filled,
|
||||
the provisioner's account now named, a write that failed; mesh-host `internal/filling` Knock): a file refused because
|
||||
the declaration did not name it yet is written once the declaration does, and the journal says each such retry;
|
||||
- writes again on every change it watches and when its node comes back, never on a timer (novox/hq ADR 0349 clause
|
||||
4): every file of the module goes through the node-engine again, so an `owned` file changed on disk is put back;
|
||||
- reports `prov.applied.<node>.<module>` and keeps the same report in `mesh-provisioner_applied` (`<node>.<module>`):
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// scriptedSecrets answers each Get from the next of its errors, then with a value, counting the asks.
|
||||
@@ -44,6 +46,48 @@ func TestARefusedSecretIsAskedAgainAfterRefusedForAndFilledWhenTheVaultHasIt(t *
|
||||
if fake.asked != 2 || v != "tok" || wait != "" || fail != "" {
|
||||
t.Fatalf("after RefusedFor: %d asks, value %q, waits %q, fails %q; want asked again and filled", fake.asked, v, wait, fail)
|
||||
}
|
||||
|
||||
// What ends a refusal before RefusedFor asks again at once (ace, 2026-10-25 21:17: refused before the declaration
|
||||
// named the module's files): the node-engine touching its write socket after an apply, which fills every module
|
||||
// again, and the module's own assignment changing. Another module's assignment does not.
|
||||
own := VaultName("g14", "claude-code", "token")
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
end func(c *Client, s *state) []string
|
||||
asked bool
|
||||
filled []string // the modules a wake fills again
|
||||
}{
|
||||
{name: "the socket touched", asked: true, filled: []string{"claude-code", "gitea", "waited"},
|
||||
end: func(c *Client, s *state) []string { return c.woken(s) }},
|
||||
{name: "its assignment changed", asked: true,
|
||||
end: func(c *Client, s *state) []string { c.forgetRefusals(s, "claude-code"); return nil }},
|
||||
{name: "another module's assignment changed", asked: false,
|
||||
end: func(c *Client, s *state) []string { c.forgetRefusals(s, "gitea"); return nil }},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
refused := errors.New("the vault refused it: g14's declaration names no such secret")
|
||||
fake := &scriptedSecrets{errs: []error{refused, refused}}
|
||||
c := &Client{Node: "g14", Secrets: fake, Now: func() time.Time { return now }}
|
||||
s := &state{secrets: map[string]heldSecret{}, plans: map[string]Plan{"claude-code": {}, "gitea": {}},
|
||||
unplaced: map[string]bool{"waited": true}}
|
||||
for _, name := range []string{name, own} {
|
||||
if _, _, fail := c.vaultValue(context.Background(), s, n, name); fail == "" {
|
||||
t.Fatal("a refusal did not fail the need")
|
||||
}
|
||||
}
|
||||
fake.asked = 0
|
||||
fake.errs = nil
|
||||
if got := row.end(c, s); row.filled != nil && fmt.Sprint(got) != fmt.Sprint(row.filled) {
|
||||
t.Errorf("filled again %v, want %v", got, row.filled)
|
||||
}
|
||||
for _, name := range []string{name, own} {
|
||||
v, _, _ := c.vaultValue(context.Background(), s, n, name)
|
||||
if (v == "tok") != row.asked {
|
||||
t.Errorf("%s: value %q after %d asks; asked again at once: want %v", name, v, fake.asked, row.asked)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestANotYetSecretIsAskedAgainOnEveryPass(t *testing.T) {
|
||||
@@ -62,3 +106,70 @@ func TestANotYetSecretIsAskedAgainOnEveryPass(t *testing.T) {
|
||||
t.Fatalf("value %q after %d asks, want filled on the third", v, fake.asked)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeEntry is one watched key's update, as far as take reads it.
|
||||
type fakeEntry struct {
|
||||
jetstream.KeyValueEntry
|
||||
key string
|
||||
val []byte
|
||||
op jetstream.KeyValueOp
|
||||
}
|
||||
|
||||
func (f fakeEntry) Key() string { return f.key }
|
||||
func (f fakeEntry) Value() []byte { return f.val }
|
||||
func (f fakeEntry) Operation() jetstream.KeyValueOp { return f.op }
|
||||
|
||||
// Issue 1945 (ace, 2026-10-25 23:51): a module that leaves the machine and is placed on it again asks the vault
|
||||
// afresh, whichever way it left or returned, so it is never filled with a generation held from before; a module
|
||||
// that stays placed keeps using what it holds.
|
||||
func TestAModulePlacedAgainAsksTheVaultAfreshAndAModuleThatStaysDoesNot(t *testing.T) {
|
||||
const assignment = `{"node":"g14","module":"build","provisioning":{}}`
|
||||
own := VaultName("g14", "build", "bus-password")
|
||||
pair := PairName("g14", "db", "g14", "build", "")
|
||||
other := VaultName("g14", "gitea", "token")
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
steps func(c *Client, s *state)
|
||||
again int // asks of the vault after the first three: the module's own secret once more, or none
|
||||
}{
|
||||
{name: "it stays placed: the assignment is put again", again: 0, steps: func(c *Client, s *state) {
|
||||
c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}})
|
||||
}},
|
||||
{name: "its assignment is deleted and put again", again: 1, steps: func(c *Client, s *state) {
|
||||
c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", op: jetstream.KeyValueDelete}})
|
||||
c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}})
|
||||
}},
|
||||
{name: "it left and the client was away: only the later assignment arrives", again: 1, steps: func(c *Client, s *state) {
|
||||
delete(s.assigned, "build")
|
||||
c.take(s, update{bucket: AssignmentsBucket, entry: fakeEntry{key: "g14.build", val: []byte(assignment)}})
|
||||
}},
|
||||
{name: "it leaves the machine", again: 1, steps: func(c *Client, s *state) {
|
||||
c.leave(context.Background(), nil, s, "build")
|
||||
}},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
fake := &scriptedSecrets{}
|
||||
c := &Client{Node: "g14", Secrets: fake, Now: time.Now}
|
||||
s := &state{secrets: map[string]heldSecret{}, plans: map[string]Plan{}, refused: map[string]string{},
|
||||
assigned: map[string]bool{"build": true}, binds: map[string]binding{}, providing: map[string]bool{},
|
||||
leftOut: map[string]string{}, needs: map[string][]byte{}, applied: map[string]bool{},
|
||||
reported: map[string][]byte{}, items: map[string]map[string]itemValue{}, recorded: map[string][]byte{},
|
||||
unplaced: map[string]bool{}}
|
||||
n := Need{Kind: "secret", Name: "bus-password"}
|
||||
ask := func(name string) { c.vaultValue(context.Background(), s, n, name) }
|
||||
ask(own)
|
||||
ask(pair)
|
||||
ask(other)
|
||||
row.steps(c, s)
|
||||
ask(own)
|
||||
ask(other)
|
||||
if got := fake.asked - 3; got != row.again {
|
||||
t.Errorf("%d asks after the first three, want %d (the module's own once more, if it was placed again)",
|
||||
got, row.again)
|
||||
}
|
||||
if _, held := s.secrets[other]; !held {
|
||||
t.Error("another module's secret was dropped")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,12 +382,7 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
case u.asked != nil:
|
||||
c.respond(s, u.asked)
|
||||
case u.socketUp:
|
||||
// Every module, not only those whose fill failed: the node-engine that made the socket may have
|
||||
// applied a composition that left a filled file as the composition had it (issue 1945).
|
||||
for m := range s.unplaced {
|
||||
dirty[m] = true
|
||||
}
|
||||
for m := range s.plans {
|
||||
for _, m := range c.woken(s) {
|
||||
dirty[m] = true
|
||||
}
|
||||
case u.bucket == delivery.HaveBucket:
|
||||
@@ -442,6 +437,35 @@ func (c *Client) Run(ctx context.Context) error {
|
||||
|
||||
func hasPlan(s *state, m string) bool { _, ok := s.plans[m]; return ok }
|
||||
|
||||
// woken is what the node-engine's write socket made or touched again fills: every module, not only those whose fill
|
||||
// was refused or failed. The node-engine makes it when it starts (issue 1165), and touches it after an apply whose
|
||||
// declaration may let through a write it refused or failed (mesh-host internal/filling Knock; ace, 2026-10-25 21:17:
|
||||
// mesh-build-go's files were refused 32 s before its want, and nothing filled them after the apply that named them).
|
||||
// That apply may also have left a filled file as the composition had it (issue 1945). What the vault refused is asked
|
||||
// again at once: it hands only what the declaration names, which may have changed too.
|
||||
func (c *Client) woken(s *state) []string {
|
||||
dropped := c.forgetRefusals(s, "")
|
||||
out := map[string]bool{}
|
||||
for m := range s.unplaced {
|
||||
out[m] = true
|
||||
}
|
||||
for m := range s.plans {
|
||||
out[m] = true
|
||||
}
|
||||
all := sortedKeys(out)
|
||||
logf("[provisioner-client] the node-engine's write socket was made or touched: filling %d module(s) again "+
|
||||
"(refused or failed before: %s; the vault's refusals asked again: %d)", len(all),
|
||||
orNone(sortedKeys(s.unplaced)), len(dropped))
|
||||
return all
|
||||
}
|
||||
|
||||
func orNone(xs []string) string {
|
||||
if len(xs) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(xs, ", ")
|
||||
}
|
||||
|
||||
// ownComposedOf is the digest of the node's own composition a have `<node>.node` says the node-engine runs; empty for a
|
||||
// deleted key or a value it cannot read.
|
||||
func ownComposedOf(e jetstream.KeyValueEntry) string {
|
||||
@@ -521,6 +545,9 @@ func (c *Client) take(s *state, u update) []string {
|
||||
gone = gone || !provider.Standing(e.Value())
|
||||
// Whom it binds, whichever machine's it is: a provider here listing its consumers is filled again.
|
||||
var providers []string
|
||||
if ended, had := s.binds[key]; gone && had {
|
||||
c.forgetBound(s, ended)
|
||||
}
|
||||
if takeBinds(s, key, e.Value(), gone) {
|
||||
providers = sortedKeys(s.providing)
|
||||
}
|
||||
@@ -530,11 +557,20 @@ func (c *Client) take(s *state, u update) []string {
|
||||
}
|
||||
delete(s.plans, module)
|
||||
delete(s.refused, module)
|
||||
if gone || !s.assigned[module] {
|
||||
// Leaving, or placed here after being absent: nothing held of the vault is served (issue 1945).
|
||||
c.forgetHeld(s, module)
|
||||
}
|
||||
if gone {
|
||||
delete(s.assigned, module)
|
||||
return append(providers, module)
|
||||
}
|
||||
s.assigned[module] = true
|
||||
// A new assignment of the module may end what the vault refused of its secrets: asked again at the next fill.
|
||||
if dropped := c.forgetRefusals(s, module); len(dropped) > 0 {
|
||||
logf("[provisioner-client] %s: its assignment changed, so the vault is asked again for %s", module,
|
||||
strings.Join(dropped, ", "))
|
||||
}
|
||||
m, p, err := planOf(c.Node, key, e.Value())
|
||||
switch {
|
||||
case errors.Is(err, errNoPlan):
|
||||
@@ -641,6 +677,14 @@ func (c *Client) leftModules(s *state) []string {
|
||||
func (c *Client) leave(ctx context.Context, kvs map[string]jetstream.KeyValue, s *state, module string) {
|
||||
prefix := c.Node + "." + module + "."
|
||||
delete(s.unplaced, module)
|
||||
// What it held of the vault goes with it: placed again, it asks afresh (issue 1945).
|
||||
c.forgetHeld(s, module)
|
||||
delete(s.providing, module)
|
||||
for k := range s.leftOut {
|
||||
if strings.HasPrefix(k, module+" ") {
|
||||
delete(s.leftOut, k)
|
||||
}
|
||||
}
|
||||
removed := 0
|
||||
for _, key := range sortedKeys(s.needs) {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
@@ -769,6 +813,8 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu
|
||||
// runtime left at the path, cleared by the engine that followed).
|
||||
s.unplaced[module] = true
|
||||
failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err))
|
||||
logf("[provisioner-client] %s: %s was not written (%v); it is filled again when the node-engine makes or "+
|
||||
"touches its write socket (after an apply that may let it through)", module, f.Path, err)
|
||||
continue
|
||||
}
|
||||
fa.Action, fa.Digest = a.Action, a.Digest
|
||||
|
||||
@@ -252,3 +252,69 @@ func (c *Client) secretChanged(s *state, body []byte) []string {
|
||||
delete(s.secrets, ch.Name)
|
||||
return []string{module}
|
||||
}
|
||||
|
||||
// forgetRefusals drops what the vault refused of a module's secrets on this machine (every module's, for module ""),
|
||||
// so the next fill asks again at once rather than after RefusedFor: the vault hands only what this machine's
|
||||
// declaration names, so a refusal can end with the module's assignment or with a declaration the node-engine applied
|
||||
// since (ace, 2026-10-25 21:17: the refusals came before the declaration named the module's files). It answers the
|
||||
// secrets dropped.
|
||||
func (c *Client) forgetRefusals(s *state, module string) []string {
|
||||
var dropped []string
|
||||
for _, name := range sortedKeys(s.secrets) {
|
||||
if s.secrets[name].refused == "" {
|
||||
continue
|
||||
}
|
||||
if module != "" && secretModule(c.Node, name) != module {
|
||||
continue
|
||||
}
|
||||
delete(s.secrets, name)
|
||||
dropped = append(dropped, name)
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
// forgetHeld drops every secret this run holds for a module of this machine, whatever it is (value or refusal), so
|
||||
// the next fill asks the vault afresh: a module that left the machine, or is placed on it again, is never served a
|
||||
// value held from before (ace, 2026-10-25 23:51: a re-assigned module was filled with the generation it held before
|
||||
// the vault minted a new one, and the bus refused the account; issue 1945). A provider here that lists its consumers
|
||||
// also loses the credentials of the consumers it held, which no name ties to one provider module. It answers the
|
||||
// names dropped.
|
||||
func (c *Client) forgetHeld(s *state, module string) []string {
|
||||
var dropped []string
|
||||
for _, name := range sortedKeys(s.secrets) {
|
||||
own := secretModule(c.Node, name) == module
|
||||
held := s.providing[module] && strings.HasPrefix(name, "pair/"+c.Node+"/")
|
||||
if own || held {
|
||||
delete(s.secrets, name)
|
||||
dropped = append(dropped, name)
|
||||
}
|
||||
}
|
||||
return dropped
|
||||
}
|
||||
|
||||
// forgetBound drops the credentials a provider here held for a consumer whose assignment ended: when the consumer
|
||||
// is assigned again the vault may hold a newer generation of them.
|
||||
func (c *Client) forgetBound(s *state, b binding) {
|
||||
for _, name := range sortedKeys(s.secrets) {
|
||||
parts := strings.Split(name, "/")
|
||||
if len(parts) >= 5 && parts[0] == "pair" && parts[3] == b.node && parts[4] == b.module {
|
||||
delete(s.secrets, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// secretModule is the module of this machine a vault name is issued for: own/<node>/<module>/<name>,
|
||||
// provision/<provider>/<provision>/<node>/<module>, pair/<provider>/<provision>/<node>/<module>[/<local>]; "" for
|
||||
// none.
|
||||
func secretModule(node, name string) string {
|
||||
parts := strings.Split(name, "/")
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "own" && parts[1] == node:
|
||||
return parts[2]
|
||||
case len(parts) == 5 && parts[0] == "provision" && parts[3] == node:
|
||||
return parts[4]
|
||||
case (len(parts) == 5 || len(parts) == 6) && parts[0] == "pair" && parts[3] == node:
|
||||
return parts[4]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package main
|
||||
|
||||
// The vault hashes the bus accounts it is handed (novox/hq ADR 0547 clause 9, first milestone; issue 1945).
|
||||
//
|
||||
// Until the vault mints bus accounts (ADR 0113 §6, issue 774), the controller mints each covered account and hands its
|
||||
// value here (ADR 0374 §1). The grants part composes the bus's user list from bcrypt hashes, and the one party that holds
|
||||
// the value beside the controller is the vault, so the vault writes the hash of each covered account it holds into its own
|
||||
// state `bus-accounts`, keyed by the bus user, once per generation, and says `account-hashed`. The value is never written
|
||||
// anywhere but the hash: the key carries the user, the hash, the generation, the fingerprint of the value as the vault's
|
||||
// other tools give it, and when.
|
||||
//
|
||||
// It hashes when a covered account is kept (handed, moved in, or replaced in place by a re-issue), at start every covered
|
||||
// account it holds with no key for its current generation, and again by hand with the tool `account_hash`. A write the
|
||||
// bus refused is kept as owed and tried again at the next minute pass, never lost silently.
|
||||
//
|
||||
// The hashes of accounts the vault holds no value for (enrolment users, persons, the controller's own) are adopted once,
|
||||
// before take-over, from the list the bus loaded (clause 9's adoption): such a key is marked adopted and is replaced by
|
||||
// the vault's own hash the moment the vault holds the account's value, never the other way round.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// BusAccountsState is the vault's state of bus-account hashes, by bus user (`mesh-vault.bus-accounts`).
|
||||
const BusAccountsState = "bus-accounts"
|
||||
|
||||
// AccountHashedEvent is what the vault says when it writes an account's hash: the user, the secret, its generation and
|
||||
// fingerprint, never the hash or the value.
|
||||
const AccountHashedEvent = "account-hashed"
|
||||
|
||||
// AccountHash is one key of BusAccountsState.
|
||||
type AccountHash struct {
|
||||
User string `json:"user"`
|
||||
// Hash is the bcrypt hash of the account's password, at the composer's cost.
|
||||
Hash string `json:"hash"`
|
||||
// Secret, Generation and Fingerprint are the vault's own secret the hash is of: absent for an adopted one.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Generation int `json:"generation,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
// Adopted marks a hash taken from the list the bus loaded, for an account the vault holds no value for.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
}
|
||||
|
||||
// AccountStore is the vault's state of bus-account hashes: read and written by the vault's instances alone.
|
||||
type AccountStore interface {
|
||||
Get(user string) (*AccountHash, error)
|
||||
Put(a AccountHash) error
|
||||
}
|
||||
|
||||
// busAccounts is AccountStore over the vault's own state through its runtime.
|
||||
type busAccounts struct{}
|
||||
|
||||
func (busAccounts) Get(user string) (*AccountHash, error) {
|
||||
e, err := stdio.State(BusAccountsState).Get(user)
|
||||
if err != nil || e == nil {
|
||||
return nil, err
|
||||
}
|
||||
var a AccountHash
|
||||
if err := json.Unmarshal(e.Value, &a); err != nil {
|
||||
return nil, fmt.Errorf("%s %s is not an account's hash: %w", BusAccountsState, user, err)
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (busAccounts) Put(a AccountHash) error {
|
||||
_, err := stdio.State(BusAccountsState).Put(a.User, a)
|
||||
return err
|
||||
}
|
||||
|
||||
// hashing is what the vault keeps of its hashing beside its store: the accounts whose hash is owed, with why.
|
||||
type hashing struct {
|
||||
mu sync.Mutex
|
||||
owed map[string]string
|
||||
}
|
||||
|
||||
func (h *hashing) owe(secret, why string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.owed == nil {
|
||||
h.owed = map[string]string{}
|
||||
}
|
||||
h.owed[secret] = why
|
||||
}
|
||||
|
||||
func (h *hashing) paid(secret string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.owed, secret)
|
||||
}
|
||||
|
||||
func (h *hashing) list() map[string]string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := map[string]string{}
|
||||
for k, v := range h.owed {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hashCost is the bcrypt cost of a hash: the controller's (inventory.hashBusPassword, bcrypt's default) unless a test
|
||||
// set the vault's own.
|
||||
func (v *Vault) hashCost() int {
|
||||
if v.HashCost != 0 {
|
||||
return v.HashCost
|
||||
}
|
||||
return bcrypt.DefaultCost
|
||||
}
|
||||
|
||||
// accountOf reads a covered account's value, `{url, fingerprint, node, [module,] user, password[, claims]}` as the
|
||||
// controller hands it, for its bus user and password. A value that names no user is read by its secret's name, as the
|
||||
// controller names the user it minted for it (a module's `<node>.<module>`, a provisioner client's
|
||||
// `provisioner.<node>`); one with no password is not an account.
|
||||
func accountOf(secret, value string) (user, password string, err error) {
|
||||
var v struct {
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(value), &v); err != nil {
|
||||
return "", "", fmt.Errorf("%s holds no account the controller hands (not JSON)", secret)
|
||||
}
|
||||
if v.Password == "" {
|
||||
return "", "", fmt.Errorf("%s holds no password", secret)
|
||||
}
|
||||
user = v.User
|
||||
if user == "" {
|
||||
parts := strings.Split(secret, "/")
|
||||
switch {
|
||||
case len(parts) == 4 && parts[3] == "broker":
|
||||
user = parts[1] + "." + parts[2]
|
||||
case len(parts) == 4 && parts[3] == "provisioner-broker":
|
||||
user = "provisioner." + parts[1]
|
||||
default:
|
||||
return "", "", fmt.Errorf("%s names no user", secret)
|
||||
}
|
||||
}
|
||||
return user, v.Password, nil
|
||||
}
|
||||
|
||||
// Hashed is what one hashing did: the key written or found, never the hash.
|
||||
type Hashed struct {
|
||||
Secret string `json:"secret"`
|
||||
User string `json:"user"`
|
||||
Generation int `json:"generation"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
At time.Time `json:"at"`
|
||||
Written bool `json:"written"`
|
||||
}
|
||||
|
||||
// HashAccount writes the hash of the covered account secret into BusAccountsState, when the key for its user is not
|
||||
// already of this generation and value (or force), and says `account-hashed`. An adopted key is replaced: the vault
|
||||
// holds the value now.
|
||||
func (v *Vault) HashAccount(secret string, force bool) (Hashed, error) {
|
||||
if v.Accounts == nil {
|
||||
return Hashed{}, errors.New("the vault has no state of bus accounts to write to")
|
||||
}
|
||||
if !Covered(secret) {
|
||||
return Hashed{}, fmt.Errorf("%s is not a bus account the vault is handed (%s)", secret, strings.Join(CoveredSecrets, ", "))
|
||||
}
|
||||
v.hashMu.Lock()
|
||||
defer v.hashMu.Unlock()
|
||||
value, rec, err := v.openHeld(secret)
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
}
|
||||
user, password, err := accountOf(secret, value)
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
}
|
||||
fp := Fingerprint(value)
|
||||
h := Hashed{Secret: secret, User: user, Generation: rec.Generation, Fingerprint: fp}
|
||||
was, err := v.Accounts.Get(user)
|
||||
if err != nil {
|
||||
v.hashes.owe(secret, "its key could not be read: "+err.Error())
|
||||
return Hashed{}, fmt.Errorf("%s %s could not be read: %w", BusAccountsState, user, err)
|
||||
}
|
||||
if !force && was != nil && !was.Adopted && was.Secret == secret && was.Generation == rec.Generation && was.Fingerprint == fp {
|
||||
v.hashes.paid(secret)
|
||||
h.At = was.At
|
||||
return h, nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), v.hashCost())
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
}
|
||||
h.At = v.Now().UTC()
|
||||
if err := v.Accounts.Put(AccountHash{User: user, Hash: string(hash), Secret: secret, Generation: rec.Generation,
|
||||
Fingerprint: fp, At: h.At}); err != nil {
|
||||
v.hashes.owe(secret, "its key could not be written: "+err.Error())
|
||||
return Hashed{}, fmt.Errorf("the hash of %s (generation %d) could not be written to %s %s, tried again at the "+
|
||||
"next pass: %w", secret, rec.Generation, BusAccountsState, user, err)
|
||||
}
|
||||
v.hashes.paid(secret)
|
||||
h.Written = true
|
||||
if v.Emit != nil {
|
||||
if err := v.Emit(AccountHashedEvent, map[string]any{"user": user, "secret": secret, "generation": rec.Generation,
|
||||
"fingerprint": fp, "at": h.At.Format(time.RFC3339Nano)}); err != nil {
|
||||
v.log("the hash of %s is written, but %s could not be said: %v", secret, AccountHashedEvent, err)
|
||||
}
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// openHeld is a secret's record and its value, opened with the vault's key.
|
||||
func (v *Vault) openHeld(secret string) (string, Record, error) {
|
||||
rec, held, err := v.Store.Read(secret)
|
||||
if err != nil {
|
||||
return "", Record{}, err
|
||||
}
|
||||
if !held {
|
||||
return "", Record{}, fmt.Errorf("the vault holds no secret named %s", secret)
|
||||
}
|
||||
value, err := openAtRest(rec, v)
|
||||
if err != nil {
|
||||
return "", Record{}, fmt.Errorf("the vault could not open its copy of %s: %w", secret, err)
|
||||
}
|
||||
return value, rec, nil
|
||||
}
|
||||
|
||||
// hashKept is what keeping a covered account does after it is kept: its hash written, or the failure said and owed.
|
||||
func (v *Vault) hashKept(secret string) {
|
||||
if v.Accounts == nil || !Covered(secret) {
|
||||
return
|
||||
}
|
||||
if _, err := v.HashAccount(secret, false); err != nil {
|
||||
v.hashes.owe(secret, err.Error())
|
||||
v.log("THE HASH OF %s IS NOT WRITTEN: %v", secret, err)
|
||||
}
|
||||
}
|
||||
|
||||
// HashHeld hashes every covered account the vault holds whose key is not of its current generation: at start. It
|
||||
// answers what it wrote and what it could not.
|
||||
func (v *Vault) HashHeld() (written []Hashed, failed map[string]string) {
|
||||
failed = map[string]string{}
|
||||
if v.Accounts == nil {
|
||||
return nil, failed
|
||||
}
|
||||
records, err := v.Store.List()
|
||||
if err != nil {
|
||||
failed["the store"] = err.Error()
|
||||
return nil, failed
|
||||
}
|
||||
for _, r := range records {
|
||||
if !Covered(r.Name) {
|
||||
continue
|
||||
}
|
||||
h, err := v.HashAccount(r.Name, false)
|
||||
if err != nil {
|
||||
v.hashes.owe(r.Name, err.Error())
|
||||
failed[r.Name] = err.Error()
|
||||
continue
|
||||
}
|
||||
if h.Written {
|
||||
written = append(written, h)
|
||||
}
|
||||
}
|
||||
return written, failed
|
||||
}
|
||||
|
||||
// HashOwed tries again every hash owed: at each minute pass. Nothing owed reads nothing.
|
||||
func (v *Vault) HashOwed() {
|
||||
owed := v.hashes.list()
|
||||
names := make([]string, 0, len(owed))
|
||||
for n := range owed {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
still := map[string]string{}
|
||||
for _, n := range names {
|
||||
if _, held, err := v.Store.Read(n); err == nil && !held {
|
||||
v.hashes.paid(n)
|
||||
continue
|
||||
}
|
||||
if _, err := v.HashAccount(n, false); err != nil {
|
||||
still[n] = err.Error()
|
||||
}
|
||||
}
|
||||
if len(still) > 0 {
|
||||
v.log("%d of %d owed bus-account hashes are still owed, tried again at the next pass: %v", len(still), len(names), still)
|
||||
}
|
||||
}
|
||||
|
||||
// HashUser is the tool account_hash: the covered account whose value names user, hashed again now.
|
||||
func (v *Vault) HashUser(user, caller string) (Hashed, error) {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return Hashed{}, errors.New("account_hash names a bus user (`user`), e.g. ace.mesh-build-go")
|
||||
}
|
||||
records, err := v.Store.List()
|
||||
if err != nil {
|
||||
return Hashed{}, err
|
||||
}
|
||||
for _, r := range records {
|
||||
if !Covered(r.Name) {
|
||||
continue
|
||||
}
|
||||
value, err := openAtRest(r, v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if u, _, err := accountOf(r.Name, value); err == nil && u == user {
|
||||
h, err := v.HashAccount(r.Name, true)
|
||||
if err == nil {
|
||||
v.log("hashed %s again (%s, generation %d), asked by %s", user, r.Name, h.Generation, someoneOr(caller, "a tool"))
|
||||
} else {
|
||||
v.log("did not hash %s again (%s), asked by %s: %v", user, r.Name, someoneOr(caller, "a tool"), err)
|
||||
}
|
||||
return h, err
|
||||
}
|
||||
}
|
||||
v.log("did not hash %s again, asked by %s: the vault holds no bus account of it", user, someoneOr(caller, "a tool"))
|
||||
return Hashed{}, fmt.Errorf("the vault holds no bus account of %s: an account it holds no value for is adopted, "+
|
||||
"never hashed here", user)
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -197,15 +196,27 @@ func zero(b []byte) {
|
||||
|
||||
// CoveredSecrets are the names, in a module's own secrets, of the bus accounts the controller mints for a core module
|
||||
// or a module marked moved and hands to the vault (novox/hq ADR 0374, issue 787): the module's account on the bus
|
||||
// (`broker`) and, for the machine's provisioner client, its own (`provisioner-broker`).
|
||||
var CoveredSecrets = []string{"broker", "provisioner-broker"}
|
||||
// (`broker`), for the machine's provisioner client its own (`provisioner-broker`), and for each process of its bus
|
||||
// declarations its own (`broker-<process id>`, ADR 0436 §2), named here by ProcessSecretPrefix.
|
||||
var CoveredSecrets = []string{"broker", "provisioner-broker", ProcessSecretPrefix + "<process id>"}
|
||||
|
||||
// Covered says whether secret is a covered account: a module's own secret named one of CoveredSecrets. The vault
|
||||
// cannot tell core or moved from a module's manifest; the controller, which can, is the only caller that may hand
|
||||
// one in, and the name keeps what it hands to these.
|
||||
// ProcessSecretPrefix starts a process's own bus account's secret, `broker-<process id>` (the controller's
|
||||
// processSecretName).
|
||||
const ProcessSecretPrefix = "broker-"
|
||||
|
||||
// Covered says whether secret is a covered account: a module's own secret named `broker`, `provisioner-broker` or
|
||||
// `broker-<process id>`. The vault cannot tell core or moved from a module's manifest; the controller, which can, is the
|
||||
// only caller that may hand one in, and the name keeps what it hands to these.
|
||||
func Covered(secret string) bool {
|
||||
parts := strings.Split(secret, "/")
|
||||
return len(parts) == 4 && parts[0] == "own" && slices.Contains(CoveredSecrets, parts[3])
|
||||
if len(parts) != 4 || parts[0] != "own" {
|
||||
return false
|
||||
}
|
||||
name := parts[3]
|
||||
if id, process := strings.CutPrefix(name, ProcessSecretPrefix); process {
|
||||
return id != ""
|
||||
}
|
||||
return name == "broker" || name == "provisioner-broker"
|
||||
}
|
||||
|
||||
// AcceptOpen opens a call to take a value given at the controller's terminal for secret, which the terminal
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// fakeAccounts is the vault's state bus-accounts: every key written, and a refusal when told.
|
||||
type fakeAccounts struct {
|
||||
mu sync.Mutex
|
||||
keys map[string]AccountHash
|
||||
puts int
|
||||
refuse bool
|
||||
}
|
||||
|
||||
func (f *fakeAccounts) Get(user string) (*AccountHash, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a, ok := f.keys[user]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccounts) Put(a AccountHash) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.refuse {
|
||||
return errors.New("Permissions Violation for Publish to $KV.mesh-vault_bus-accounts")
|
||||
}
|
||||
if f.keys == nil {
|
||||
f.keys = map[string]AccountHash{}
|
||||
}
|
||||
f.keys[a.User] = a
|
||||
f.puts++
|
||||
return nil
|
||||
}
|
||||
|
||||
// hashOf says whether the key of user is the hash of password at generation, of the vault's own value.
|
||||
func (f *fakeAccounts) hashOf(user, password string, generation int) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
a, ok := f.keys[user]
|
||||
return ok && !a.Adopted && a.Generation == generation && bcrypt.CompareHashAndPassword([]byte(a.Hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func eventsNamed(v testVault, from int, name string) int {
|
||||
n := 0
|
||||
for _, e := range v.bus.events[from:] {
|
||||
if e["event"] == name {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A bus account the controller minted for a core or moved module is handed to the vault with origin issued (novox/hq
|
||||
// ADR 0374, issue 787): kept as that module's own secret on that machine, served to the controller's core get and to
|
||||
// the moved module's client, replaced in place on a re-issue with `secret-changed`, and never made anew by the vault.
|
||||
@@ -82,6 +136,93 @@ func TestAHandedBusAccountIsKeptServedReplacedAndNeverMadeByTheVault(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// The vault hashes what it is handed (ADR 0547 clause 9): once per generation into bus-accounts.<user>, saying
|
||||
// account-hashed; a refused write is owed and written at the next pass; at start every held account without a key of its
|
||||
// generation; an adopted key is replaced by the vault's own; never the value, and never the hash in what it says.
|
||||
func TestAHandedBusAccountIsHashedOncePerGeneration(t *testing.T) {
|
||||
const secret, user = "own/ace/mesh-build-go/broker", "ace.mesh-build-go"
|
||||
account := func(pw string) string {
|
||||
return `{"url":"nats://x","node":"ace","module":"mesh-build-go","user":"` + user + `","password":"` + pw + `"}`
|
||||
}
|
||||
v := newVault(t, nil)
|
||||
acc := &fakeAccounts{}
|
||||
v.Accounts = acc
|
||||
for _, step := range []struct {
|
||||
name string
|
||||
do func() error
|
||||
password string
|
||||
generation int
|
||||
puts int
|
||||
said int
|
||||
}{
|
||||
{"handed: hashed once, said", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-1")); return err }, "pw-1", 1, 1, 1},
|
||||
{"the same value again: nothing", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-1")); return err }, "pw-1", 1, 1, 0},
|
||||
{"start with a key of this generation: nothing", func() error { _, f := v.HashHeld(); return mapErr(f) }, "pw-1", 1, 1, 0},
|
||||
{"a re-issue: the new generation hashed", func() error { _, err := handIssued(t, v, ControllerCaller, secret, account("pw-2")); return err }, "pw-2", 2, 2, 1},
|
||||
{"a refused write is owed, not lost", func() error {
|
||||
acc.refuse = true
|
||||
_, err := handIssued(t, v, ControllerCaller, secret, account("pw-3"))
|
||||
if _, owed := v.hashes.list()[secret]; !owed {
|
||||
return errors.New("the refused hash is not owed")
|
||||
}
|
||||
return err
|
||||
}, "pw-2", 2, 2, 0},
|
||||
{"the next pass writes what is owed", func() error { acc.refuse = false; v.HashOwed(); return nil }, "pw-3", 3, 3, 1},
|
||||
{"an adopted key is replaced at start", func() error {
|
||||
acc.keys[user] = AccountHash{User: user, Hash: "$2a$10$adopted", Adopted: true}
|
||||
_, f := v.HashHeld()
|
||||
return mapErr(f)
|
||||
}, "pw-3", 3, 4, 1},
|
||||
{"by hand, again", func() error { _, err := v.HashUser(user, "person.operator"); return err }, "pw-3", 3, 5, 1},
|
||||
{"a value with no user is read by its secret's name", func() error {
|
||||
_, err := handIssued(t, v, ControllerCaller, "own/g14/dunst/provisioner-broker", `{"password":"pw-p"}`)
|
||||
if !acc.hashOf("provisioner.g14", "pw-p", 1) {
|
||||
return errors.New("provisioner.g14 is not hashed")
|
||||
}
|
||||
return err
|
||||
}, "pw-3", 3, 6, 1},
|
||||
{"a process's own account (ADR 0436 §2) is covered and hashed", func() error {
|
||||
_, err := handIssued(t, v, ControllerCaller, "own/ace/mesh-build-go/broker-worker",
|
||||
`{"node":"ace","module":"mesh-build-go","user":"ace.mesh-build-go.worker","password":"pw-w"}`)
|
||||
if !acc.hashOf("ace.mesh-build-go.worker", "pw-w", 1) {
|
||||
return errors.New("ace.mesh-build-go.worker is not hashed")
|
||||
}
|
||||
if _, rerr := v.Rotate(ControllerCaller, "own/ace/mesh-build-go/broker-worker", "jochen", "x"); rerr == nil {
|
||||
return errors.New("the vault rotated a process's covered account")
|
||||
}
|
||||
if Covered("own/ace/mesh-build-go/broker-") || Covered("own/ace/mesh-build-go/brokers") {
|
||||
return errors.New("a name that is no process account is covered")
|
||||
}
|
||||
return err
|
||||
}, "pw-3", 3, 7, 1},
|
||||
} {
|
||||
before := len(v.bus.events)
|
||||
if err := step.do(); err != nil {
|
||||
t.Fatalf("%s: %v", step.name, err)
|
||||
}
|
||||
if !acc.hashOf(user, step.password, step.generation) || acc.puts != step.puts || eventsNamed(v, before, AccountHashedEvent) != step.said {
|
||||
t.Errorf("%s: key %+v, %d writes (want %d), %d said (want %d)", step.name, acc.keys[user], acc.puts, step.puts,
|
||||
eventsNamed(v, before, AccountHashedEvent), step.said)
|
||||
}
|
||||
}
|
||||
if _, err := v.HashUser("nobody.here", "person.operator"); err == nil {
|
||||
t.Error("an account the vault holds no value for was hashed by hand")
|
||||
}
|
||||
said := everything(t, v)
|
||||
for _, never := range []string{"pw-1", "pw-2", "pw-3", acc.keys[user].Hash} {
|
||||
if strings.Contains(said, never) {
|
||||
t.Fatalf("%q is in something the vault said or wrote", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mapErr(failed map[string]string) error {
|
||||
for k, v := range failed {
|
||||
return errors.New(k + ": " + v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A core module's handed account is the one the controller's core get is answered, sealed to its machine's key.
|
||||
func TestAHandedCoreAccountIsKeptAsTheModulesOwnSecret(t *testing.T) {
|
||||
const natsBroker = "own/anchor/nats/broker"
|
||||
|
||||
@@ -50,7 +50,7 @@ func build() *Env {
|
||||
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say}
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -102,8 +102,16 @@ func main() {
|
||||
go func() {
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
// At start, every covered account it holds whose hash is not of its current generation (ADR 0547 clause 9).
|
||||
if env.Vault != nil {
|
||||
written, failed := env.Vault.HashHeld()
|
||||
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
|
||||
}
|
||||
for {
|
||||
env.pass()
|
||||
if env.Vault != nil {
|
||||
env.Vault.HashOwed()
|
||||
}
|
||||
env.pairPass()
|
||||
if env.Custody != nil {
|
||||
env.Custody.Pass()
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -340,6 +341,32 @@ func definitions(env *Env) []toolDef {
|
||||
}
|
||||
return env.Vault.Store.Handed(limitOf(a))
|
||||
}},
|
||||
{Name: "secret_own",
|
||||
Description: "The secrets the vault keeps for modules themselves (`own/<node>/<module>/<name>`, e.g. a module's " +
|
||||
"bus account `broker`): for each its name, generation, the fingerprint of the value it holds now " +
|
||||
"(sha256:<hex>), when it was made and last changed, by whom, where the value came from, and whether it is a " +
|
||||
"covered bus account; for a bus account, its user and whether the hash of this generation is written in " +
|
||||
"`bus-accounts` (hashed, hashedAt, or why not). Narrow by node, module or name prefix. At most 1000 rows; " +
|
||||
"says when cut. Never a value or a hash.",
|
||||
Input: map[string]any{"node": str("only this machine's secrets"), "module": str("only this module's secrets"),
|
||||
"name": str("only secrets whose name (the last part) starts with this, e.g. broker")},
|
||||
run: func(_ string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
return ownAnswer(env.Vault, arg(a, "node"), arg(a, "module"), arg(a, "name"))
|
||||
}},
|
||||
{Name: "account_hash",
|
||||
Description: "Hash one covered bus account again now (ADR 0547 clause 9): the vault writes the bcrypt hash of " +
|
||||
"the account it holds for the bus user into its state `bus-accounts` and says `account-hashed`, as it does " +
|
||||
"when the account is handed. Answers the generation and fingerprint hashed, never the hash or the value.",
|
||||
Input: map[string]any{"user": str("the bus user, e.g. ace.mesh-build-go")},
|
||||
run: func(caller string, a map[string]any) (any, error) {
|
||||
if env.Vault == nil {
|
||||
return nil, errors.New("the vault is not ready")
|
||||
}
|
||||
return env.Vault.HashUser(arg(a, "user"), caller)
|
||||
}},
|
||||
{Name: "secret_vault_export",
|
||||
Description: "Every secret the vault itself holds, as its copy sealed to the operator key, in the shape of " +
|
||||
"`secret export`: saved to a file, `nox secret recover vault mesh-vault <name> --key <operator key file> " +
|
||||
@@ -661,3 +688,85 @@ func tools(env *Env) []stdio.Tool {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownRowsMax bounds what secret_own answers.
|
||||
const ownRowsMax = 1000
|
||||
|
||||
// ownAnswer lists the own secrets the store holds, narrowed by node, module and name prefix: fingerprints only.
|
||||
func ownAnswer(v *Vault, node, module, prefix string) (map[string]any, error) {
|
||||
records, err := v.Store.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []map[string]any{}
|
||||
matched := 0
|
||||
for _, r := range records {
|
||||
parts := strings.Split(r.Name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" {
|
||||
continue
|
||||
}
|
||||
if (node != "" && parts[1] != node) || (module != "" && parts[2] != module) || !strings.HasPrefix(parts[3], prefix) {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
if len(rows) >= ownRowsMax {
|
||||
continue
|
||||
}
|
||||
fp := ""
|
||||
var note any
|
||||
if value, err := seal.Open(r.AtRest, v.Own.PrivateKey); err != nil {
|
||||
note = "the vault could not open its copy: " + err.Error()
|
||||
} else {
|
||||
fp = Fingerprint(value)
|
||||
}
|
||||
row := map[string]any{"name": r.Name, "node": parts[1], "module": parts[2], "secret": parts[3],
|
||||
"generation": r.Generation, "fingerprint": fp, "made": r.MadeAt, "changed": r.ChangedAt,
|
||||
"changedBy": r.ChangedBy, "origin": r.Origin, "busAccount": Covered(r.Name)}
|
||||
if r.ChangedVia != "" {
|
||||
row["changedVia"] = r.ChangedVia
|
||||
}
|
||||
if note != nil {
|
||||
row["error"] = note
|
||||
}
|
||||
if Covered(r.Name) && fp != "" {
|
||||
hashedRow(v, r, fp, row)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return map[string]any{"secrets": rows, "count": len(rows), "matched": matched, "cut": matched > len(rows),
|
||||
"note": "fingerprint is sha256 of the value the vault holds; a bus account's hash is in the state bus-accounts, never here"}, nil
|
||||
}
|
||||
|
||||
// hashedRow says of a covered account's row whether the hash of its current generation is written in bus-accounts.
|
||||
func hashedRow(v *Vault, r Record, fp string, row map[string]any) {
|
||||
value, err := openAtRest(r, v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
user, _, err := accountOf(r.Name, value)
|
||||
if err != nil {
|
||||
row["hashed"], row["hashWhy"] = false, err.Error()
|
||||
return
|
||||
}
|
||||
row["user"] = user
|
||||
if v.Accounts == nil {
|
||||
row["hashed"], row["hashWhy"] = false, "the vault has no state of bus accounts"
|
||||
return
|
||||
}
|
||||
h, err := v.Accounts.Get(user)
|
||||
switch {
|
||||
case err != nil:
|
||||
row["hashed"], row["hashWhy"] = false, "its key could not be read: "+err.Error()
|
||||
case h == nil:
|
||||
row["hashed"], row["hashWhy"] = false, "no key"
|
||||
case h.Adopted:
|
||||
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, "the key is adopted from the loaded list, not of this value"
|
||||
case h.Secret != r.Name || h.Generation != r.Generation || h.Fingerprint != fp:
|
||||
row["hashed"], row["hashedAt"], row["hashWhy"] = false, h.At, fmt.Sprintf("the key is of %s generation %d", h.Secret, h.Generation)
|
||||
default:
|
||||
row["hashed"], row["hashedAt"] = true, h.At
|
||||
}
|
||||
if why, owed := v.hashes.list()[r.Name]; owed {
|
||||
row["hashOwed"] = why
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,3 +150,72 @@ func contains(list []string, s string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSecretOwnListsFingerprintsOfOwnSecretsOnly(t *testing.T) {
|
||||
v := newVault(t, AllowList{})
|
||||
acc := &fakeAccounts{}
|
||||
v.Accounts = acc
|
||||
for name, value := range map[string]string{"own/ace/mesh-build-go/broker": "pw-one", "own/ace/mesh-build-go/token": "tok-secret",
|
||||
"own/g14/dunst/broker": `{"user":"g14.dunst","password":"pw-two"}`, "pair/postgres/gitea": "pair-value"} {
|
||||
if _, err := v.Put(name, value, "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := v.Put("own/ace/mesh-build-go/broker", "pw-one-b", "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own := toolNamed(t, definitions(&Env{Vault: v.Vault}), "secret_own")
|
||||
for _, c := range []struct {
|
||||
args map[string]any
|
||||
names []string
|
||||
}{
|
||||
{map[string]any{}, []string{"own/ace/mesh-build-go/broker", "own/ace/mesh-build-go/token", "own/g14/dunst/broker"}},
|
||||
{map[string]any{"node": "ace"}, []string{"own/ace/mesh-build-go/broker", "own/ace/mesh-build-go/token"}},
|
||||
{map[string]any{"module": "dunst"}, []string{"own/g14/dunst/broker"}},
|
||||
{map[string]any{"name": "bro"}, []string{"own/ace/mesh-build-go/broker", "own/g14/dunst/broker"}},
|
||||
{map[string]any{"node": "ace", "name": "broker"}, []string{"own/ace/mesh-build-go/broker"}},
|
||||
{map[string]any{"node": "nowhere"}, nil},
|
||||
} {
|
||||
got, err := own.Run(c.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(got)
|
||||
for _, value := range []string{"pw-one", "pw-two", "tok-secret", "pair-value"} {
|
||||
if strings.Contains(string(raw), value) {
|
||||
t.Errorf("%v returned a value: %s", c.args, raw)
|
||||
}
|
||||
}
|
||||
rows := got.(map[string]any)["secrets"].([]map[string]any)
|
||||
if len(rows) != len(c.names) || got.(map[string]any)["cut"] != false {
|
||||
t.Fatalf("%v said %s", c.args, raw)
|
||||
}
|
||||
for i, n := range c.names {
|
||||
if rows[i]["name"] != n {
|
||||
t.Errorf("%v row %d is %v, want %s", c.args, i, rows[i]["name"], n)
|
||||
}
|
||||
}
|
||||
if len(rows) > 0 && rows[0]["name"] == "own/ace/mesh-build-go/broker" {
|
||||
if rows[0]["generation"] != 2 || rows[0]["fingerprint"] != Fingerprint("pw-one-b") || rows[0]["busAccount"] != true ||
|
||||
rows[0]["changedBy"] != "the test" || rows[0]["hashed"] != false || rows[0]["hashWhy"] == nil {
|
||||
t.Errorf("%v: broker row said %v", c.args, rows[0])
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r["name"] == "own/g14/dunst/broker" && (r["hashed"] != true || r["user"] != "g14.dunst" || r["hashedAt"] == nil) {
|
||||
t.Errorf("%v: a hashed account's row said %v", c.args, r)
|
||||
}
|
||||
if r["name"] == "own/ace/mesh-build-go/token" && r["hashed"] != nil {
|
||||
t.Errorf("%v: a secret that is no bus account says hashed: %v", c.args, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
hash := toolNamed(t, definitions(&Env{Vault: v.Vault}), "account_hash")
|
||||
got, err := hash.Run(map[string]any{"user": "g14.dunst"})
|
||||
if h, _ := got.(Hashed); err != nil || !h.Written || h.Generation != 1 || acc.puts != 2 {
|
||||
t.Errorf("account_hash = %+v, %v (%d writes)", got, err, acc.puts)
|
||||
}
|
||||
if _, err := hash.Run(map[string]any{"user": "ace.mesh-build-go"}); err == nil {
|
||||
t.Error("account_hash hashed a value that is no account")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,15 @@ type Vault struct {
|
||||
Ask func(key string, body any) (json.RawMessage, error)
|
||||
Now func() time.Time
|
||||
Log func(format string, args ...any)
|
||||
// Accounts is the vault's state of bus-account hashes (accounts.go, ADR 0547 clause 9): nil writes none.
|
||||
Accounts AccountStore
|
||||
// HashCost is the bcrypt cost of those hashes: zero is the controller's, bcrypt's default.
|
||||
HashCost int
|
||||
|
||||
mu sync.Mutex
|
||||
// hashMu keeps one hashing at a time, and hashes what is owed (accounts.go).
|
||||
hashMu sync.Mutex
|
||||
hashes hashing
|
||||
// callsMu guards pending: the keys of the calls open now (intake.go), never written anywhere.
|
||||
callsMu sync.Mutex
|
||||
pending map[string]*callKey
|
||||
@@ -96,8 +103,20 @@ type keepOptions struct {
|
||||
movedFrom *MovedFrom
|
||||
}
|
||||
|
||||
// keepWith is keep with the move's options.
|
||||
// keepWith is keep with the move's options. A covered bus account kept anew has its hash written once it is kept
|
||||
// (accounts.go), outside the store's lock.
|
||||
func (v *Vault) keepWith(name, value, by, via, origin string, opt keepOptions) (Changed, error) {
|
||||
c, err := v.keepLocked(name, value, by, via, origin, opt)
|
||||
if err == nil && !c.Unchanged {
|
||||
v.hashKept(name)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// openAtRest opens a record's value with the vault's own key.
|
||||
func openAtRest(r Record, v *Vault) (string, error) { return seal.Open(r.AtRest, v.Own.PrivateKey) }
|
||||
|
||||
func (v *Vault) keepLocked(name, value, by, via, origin string, opt keepOptions) (Changed, error) {
|
||||
if err := validName(name); err != nil {
|
||||
return Changed{}, err
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
@@ -104,7 +105,7 @@ func newVault(t *testing.T, askers Askers) testVault {
|
||||
bus := &published{}
|
||||
clock := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
v := &Vault{Store: store, Own: own, Askers: askers, Emit: bus.emit,
|
||||
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf}
|
||||
Now: func() time.Time { clock = clock.Add(time.Second); return clock }, Log: t.Logf, HashCost: bcrypt.MinCost}
|
||||
pub, priv := operatorKey(t)
|
||||
if _, err := v.SetOperatorKey(asPEM(t, pub), "the test"); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -128,8 +129,9 @@ func filesUnder(t *testing.T, dir string) map[string]string {
|
||||
|
||||
// ---- no bucket holds a secret ---------------------------------------------------------------------------
|
||||
|
||||
// The manifest declares no bucket, and the code opens none: the vault's only place for a secret is its own
|
||||
// store on its own disk (ADR 0349 §7, ADR 0201 §13). It reads one bucket of another's, and only reads it: the
|
||||
// The manifest declares one bucket, and no secret is in it: the vault's only place for a secret is its own store on its
|
||||
// own disk (ADR 0349 §7, ADR 0201 §13). The one bucket is bus-accounts, the bcrypt hashes of the bus accounts it holds
|
||||
// (ADR 0547 clause 9), opened in accounts.go alone. It reads one bucket of another's, and only reads it: the
|
||||
// controller's assignments, for what each node's plan names (askers.go, design 57 V4).
|
||||
func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
@@ -140,8 +142,9 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, ok := manifest["state"]; ok {
|
||||
t.Errorf("the manifest declares buckets %v: a secret is in no bucket, sealed or not (ADR 0201 §13)", s)
|
||||
if s, _ := json.Marshal(manifest["state"]); string(s) != `[{"history":2,"name":"bus-accounts"}]` {
|
||||
t.Errorf("the manifest declares buckets %s: a secret is in no bucket, sealed or not (ADR 0201 §13), and the one "+
|
||||
"bucket is the accounts' hashes (ADR 0547 clause 9)", s)
|
||||
}
|
||||
if r, _ := json.Marshal(manifest["reads"]); string(r) != `["mesh-controller.assignments"]` {
|
||||
t.Errorf("the manifest reads %s: the vault reads the controller's assignments alone", r)
|
||||
@@ -157,6 +160,11 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
// binds it remakes a provider's credential on, pair.go).
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Keys(", "", 1))
|
||||
// The accounts' hashes, in accounts.go alone: one read and one write of its own bucket.
|
||||
if f == "accounts.go" {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
|
||||
}
|
||||
if m := bucket.Find(code); m != nil {
|
||||
t.Errorf("%s opens a bucket (%s): the vault keeps every secret in its own store only", f, m)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,18 @@
|
||||
"emits": [
|
||||
"provisioned",
|
||||
"rotated",
|
||||
"secret-changed"
|
||||
"secret-changed",
|
||||
"account-hashed"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "bus-accounts",
|
||||
"history": 2
|
||||
}
|
||||
],
|
||||
"receives": {
|
||||
"secret": "${dir:grants}/mesh.json"
|
||||
},
|
||||
@@ -145,6 +152,8 @@
|
||||
"secret_holders",
|
||||
"secret_verify",
|
||||
"secret_handed",
|
||||
"secret_own",
|
||||
"account_hash",
|
||||
"secret_asks",
|
||||
"secret_vault_export",
|
||||
"provisioner_retirement",
|
||||
|
||||
+179
-15
@@ -61,10 +61,74 @@ const ONLYOFFICE_KEYS: ReadonlyArray<readonly [occKey: string, configKey: string
|
||||
|
||||
let saidOnlyOfficeNotGiven = false;
|
||||
|
||||
/**
|
||||
* The one Nextcloud account the module makes and keeps for itself: the same name files/server.env
|
||||
* gives NEXTCLOUD_ADMIN_USER (novox/hq issue 1253). Only this account is ever repaired; a `user`
|
||||
* setting naming any other account is used as given and never written to.
|
||||
*/
|
||||
export const MESH_ACCOUNT = "mesh-admin";
|
||||
|
||||
/**
|
||||
* Nextcloud refused the mesh's own login on its OCS surface (HTTP 401), or throttled it after
|
||||
* refusals (HTTP 429, its brute-force protection). Said by its status and the account, never a
|
||||
* password; the events loop backs off on it instead of asking again every tick.
|
||||
*/
|
||||
export class LoginRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly user: string, detail: string) {
|
||||
super(`Nextcloud refused the login of ${user} (${status}${detail ? ": " + detail : ""})`);
|
||||
this.name = "LoginRefusedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** The OCS envelope's own message, the one line worth saying of a refusal; empty when none. */
|
||||
function ocsMessage(body: string): string {
|
||||
const xml = /<message>([^<]*)<\/message>/.exec(body);
|
||||
if (xml) return xml[1].trim();
|
||||
try { return String(JSON.parse(body)?.ocs?.meta?.message ?? "").trim(); } catch { return ""; }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to leave a failing poll alone: the base interval after the first failure, doubling on
|
||||
* each further one up to `maxMs`, and back to every tick once a poll passes. `due` says whether a
|
||||
* tick should poll; `failed` records a failure and answers whether it is the first of a run (said
|
||||
* loud) or the one that reached the ceiling (said once more), so a failure is never said every tick.
|
||||
*/
|
||||
export class Backoff {
|
||||
private delayMs = 0;
|
||||
private nextAt = 0;
|
||||
private failures = 0;
|
||||
|
||||
constructor(private readonly baseMs: number, private readonly maxMs: number) {}
|
||||
|
||||
due(now: number): boolean {
|
||||
return now >= this.nextAt;
|
||||
}
|
||||
|
||||
failed(now: number): { first: boolean; atCeiling: boolean; delayMs: number } {
|
||||
this.failures += 1;
|
||||
const before = this.delayMs;
|
||||
this.delayMs = before === 0 ? this.baseMs : Math.min(before * 2, this.maxMs);
|
||||
this.nextAt = now + this.delayMs;
|
||||
return { first: this.failures === 1, atCeiling: this.delayMs === this.maxMs && before !== this.maxMs, delayMs: this.delayMs };
|
||||
}
|
||||
|
||||
/** A poll passed; answers whether it ended a run of failures. */
|
||||
passed(): boolean {
|
||||
const wasFailing = this.failures > 0;
|
||||
this.failures = 0;
|
||||
this.delayMs = 0;
|
||||
this.nextAt = 0;
|
||||
return wasFailing;
|
||||
}
|
||||
}
|
||||
|
||||
export class NextcloudClient {
|
||||
// Whether the last connection check failed (novox/hq issue 1832): while it did, every tick runs
|
||||
// the check again, even when nothing changed, until it passes.
|
||||
private lastCheckFailed = false;
|
||||
// Whether the account was already made right since the last accepted login (novox/hq issue
|
||||
// 1253): a refusal makes it right once, and a refusal after that is said, not repaired again.
|
||||
private accountMadeRight = false;
|
||||
|
||||
constructor(
|
||||
private readonly container: string,
|
||||
@@ -76,15 +140,17 @@ export class NextcloudClient {
|
||||
/**
|
||||
* Build from the module's resolved environment. occ needs only the container name (default
|
||||
* "nextcloud"); the OCS surface needs the admin password the module keeps as its own secret
|
||||
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default admin, URL the local
|
||||
* (MESH_NEXTCLOUD_ADMIN_PASSWORD, user MESH_NEXTCLOUD_ADMIN_USER default mesh-admin, URL the local
|
||||
* container). The admin password is treated as the "configured for mesh administration" signal:
|
||||
* throws without it, and the module then contributes nothing rather than failing.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): NextcloudClient {
|
||||
const cfg = meshConfig(env.MESH_NEXTCLOUD_CONFIG_FILE);
|
||||
const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud";
|
||||
const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
||||
const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin";
|
||||
// An empty setting is the manifest's "empty is the default", never an empty name.
|
||||
const container = cfg.container || env.MESH_NEXTCLOUD_CONTAINER || "nextcloud";
|
||||
const ocsUrl = cfg.url || env.MESH_NEXTCLOUD_URL || `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`;
|
||||
// No "admin" fallback: that is what a hand-made install calls its human administrator.
|
||||
const adminUser = cfg.user || env.MESH_NEXTCLOUD_ADMIN_USER || MESH_ACCOUNT;
|
||||
const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE;
|
||||
ignoreSecretSetting(cfg, "password");
|
||||
const adminPassword = env.MESH_NEXTCLOUD_ADMIN_PASSWORD
|
||||
@@ -100,20 +166,28 @@ export class NextcloudClient {
|
||||
* Run occ inside the container as the web user, returning its stdout, throwing its own message.
|
||||
* With `opts.input` given, occ reads it from stdin (`docker exec -i`) instead of taking it on the
|
||||
* command line — the only way a secret value reaches occ without sitting in the docker CLI's argv,
|
||||
* the container's php argv and docker's exec record (novox/hq issue 1831).
|
||||
* the container's php argv and docker's exec record (novox/hq issue 1831). With `opts.env` given,
|
||||
* each variable is passed by name alone (`docker exec -e NAME`), its value taken from the docker
|
||||
* CLI's own environment, so it is on no argv either (occ's `--password-from-env` reads OC_PASS).
|
||||
*/
|
||||
occ(args: string[], opts: { input?: string } = {}): string {
|
||||
const dockerArgs = opts.input !== undefined
|
||||
? ["exec", "-i", "-u", "www-data", this.container, "php", "occ", ...args]
|
||||
: ["exec", "-u", "www-data", this.container, "php", "occ", ...args];
|
||||
occ(args: string[], opts: { input?: string; env?: Record<string, string> } = {}): string {
|
||||
const dockerArgs = ["exec"];
|
||||
if (opts.input !== undefined) dockerArgs.push("-i");
|
||||
for (const name of Object.keys(opts.env ?? {})) dockerArgs.push("-e", name);
|
||||
dockerArgs.push("-u", "www-data", this.container, "php", "occ", ...args);
|
||||
try {
|
||||
return execFileSync("docker", dockerArgs, {
|
||||
encoding: "utf8", timeout: 60_000,
|
||||
...(opts.input !== undefined ? { input: opts.input } : {}),
|
||||
...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
|
||||
}).trim();
|
||||
} catch (err: any) {
|
||||
const detail = String(err?.stderr ?? err?.stdout ?? err?.message ?? "").trim();
|
||||
throw new Error(detail || `occ produced no output — is the ${this.container} container running?`);
|
||||
// occ writes many refusals ("user not found") to stdout with an empty stderr: say both.
|
||||
const detail = [err?.stderr, err?.stdout].map((x) => String(x ?? "").trim()).filter(Boolean).join("\n")
|
||||
|| String(err?.message ?? "").trim();
|
||||
// The exit status rides along, so a caller can tell occ's own answer from a failure.
|
||||
throw Object.assign(new Error(detail || `occ produced no output — is the ${this.container} container running?`),
|
||||
{ status: typeof err?.status === "number" ? err.status : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,14 +262,75 @@ export class NextcloudClient {
|
||||
return { enabled: Object.keys(parsed.enabled ?? {}), disabled: Object.keys(parsed.disabled ?? {}) };
|
||||
}
|
||||
|
||||
/** List every share, over the OCS Sharing API with the admin credentials. */
|
||||
async listShares(): Promise<NextcloudShare[]> {
|
||||
/**
|
||||
* Makes the mesh's own account in Nextcloud match the module's own secret (novox/hq issue 1253).
|
||||
* The image creates NEXTCLOUD_ADMIN_USER only when it installs a fresh instance, so an instance
|
||||
* the mesh adopted never has it, and a secret made anew never reaches it. Only the module's own
|
||||
* account (MESH_ACCOUNT) is touched: under any other name it writes nothing and throws
|
||||
* LoginRefusedError. Through occ: whether the account exists is read from `user:info`, whose
|
||||
* exit status 1 with "not found" means missing (user:list pages, so it is not asked); a missing
|
||||
* one is added, an existing one gets its password set to the secret. It gets no admin group: the
|
||||
* shares poll needs no admin rights (least privilege). The password goes by OC_PASS
|
||||
* (`--password-from-env`), never on an argv. Nextcloud's brute-force record is left alone: it is
|
||||
* shared by every client behind the same address, and the caller's back-off outlasts it. A
|
||||
* failing occ is thrown as LoginRefusedError carrying occ's own message.
|
||||
*/
|
||||
ensureAdminAccount(): "created" | "password-set" {
|
||||
if (this.adminUser !== MESH_ACCOUNT) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `not repaired: only ${MESH_ACCOUNT}, the module's own account, is`);
|
||||
}
|
||||
const pass = { env: { OC_PASS: this.adminPassword } };
|
||||
try {
|
||||
if (!this.accountExists()) {
|
||||
this.occ(["user:add", "--password-from-env", "--display-name=Mesh administration", this.adminUser], pass);
|
||||
return "created";
|
||||
}
|
||||
this.occ(["user:resetpassword", "--password-from-env", this.adminUser], pass);
|
||||
return "password-set";
|
||||
} catch (err) {
|
||||
throw new LoginRefusedError(401, this.adminUser, `repair failed: ${String((err as Error)?.message ?? err).split("\n")[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the account exists: occ user:info answers it, exit 1 and "not found" when missing. */
|
||||
private accountExists(): boolean {
|
||||
try {
|
||||
this.occ(["user:info", this.adminUser, "--output=json"]);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.status === 1 && /not found/i.test(String(err?.message ?? ""))) return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private fetchShares(): Promise<Response> {
|
||||
const auth = Buffer.from(`${this.adminUser}:${this.adminPassword}`).toString("base64");
|
||||
const res = await fetch(
|
||||
return fetch(
|
||||
`${this.ocsUrl}/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json`,
|
||||
{ headers: { Authorization: `Basic ${auth}`, "OCS-APIRequest": "true", Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List every share, over the OCS Sharing API as the mesh's own account. A refused login (401)
|
||||
* makes the account right once (ensureAdminAccount) and asks again; a refusal after that, or
|
||||
* any throttle (429), throws LoginRefusedError, so the caller backs off (novox/hq issue 1253).
|
||||
*/
|
||||
async listShares(): Promise<NextcloudShare[]> {
|
||||
let res = await this.fetchShares();
|
||||
// Only a refused login (401) is repaired. A throttle (429) is no evidence the password is
|
||||
// wrong (anyone guessing passwords from the same address causes it): it is backed off only.
|
||||
if (res.status === 401 && !this.accountMadeRight) {
|
||||
await res.text();
|
||||
this.accountMadeRight = true;
|
||||
this.ensureAdminAccount();
|
||||
res = await this.fetchShares();
|
||||
}
|
||||
if (res.status === 401 || res.status === 429) {
|
||||
throw new LoginRefusedError(res.status, this.adminUser, ocsMessage(await res.text()));
|
||||
}
|
||||
if (!res.ok) throw new Error(`Nextcloud OCS shares: ${res.status} ${ocsMessage(await res.text())}`.trim());
|
||||
this.accountMadeRight = false;
|
||||
const rows = ((await res.json())?.ocs?.data ?? []) as any[];
|
||||
return rows.map((s) => ({
|
||||
id: String(s.id),
|
||||
@@ -206,3 +341,32 @@ export class NextcloudClient {
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one poll of the shares under a back-off (novox/hq issue 1253): skipped while backing off;
|
||||
* a LoginRefusedError is said loud at the first failure and once more when the wait reaches its
|
||||
* ceiling, never on every tick; one line says when it passes again. Other errors are thrown on,
|
||||
* so the loop logs them as before.
|
||||
*/
|
||||
export async function pollSharesGuarded(
|
||||
client: NextcloudClient,
|
||||
backoff: Backoff,
|
||||
poll: (c: NextcloudClient) => Promise<void>,
|
||||
now: () => number = Date.now,
|
||||
): Promise<void> {
|
||||
if (!backoff.due(now())) return;
|
||||
try {
|
||||
await poll(client);
|
||||
} catch (err) {
|
||||
if (!(err instanceof LoginRefusedError)) throw err;
|
||||
const step = backoff.failed(now());
|
||||
const minutes = Math.round(step.delayMs / 60_000);
|
||||
if (step.first) {
|
||||
console.error(`[nextcloud] shares: ${err.message}; not asking again for ${minutes} min, then less often, up to every hour`);
|
||||
} else if (step.atCeiling) {
|
||||
console.error(`[nextcloud] shares: ${err.message}, still; asking once an hour from now on`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (backoff.passed()) console.log("[nextcloud] shares: the login is accepted again");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// existing user and share as freshly created.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { NextcloudClient } from "./client.js";
|
||||
import { Backoff, NextcloudClient, pollSharesGuarded } from "./client.js";
|
||||
|
||||
// Constructed lazily so an unconfigured node (no admin password) loads this entrypoint without
|
||||
// crashing the events host — it simply watches nothing.
|
||||
@@ -69,7 +69,11 @@ if (nextcloud) {
|
||||
run();
|
||||
};
|
||||
tick(pollUsers);
|
||||
tick(pollShares);
|
||||
// A refused login is said once and the shares are left alone for 5 minutes, doubling to an hour,
|
||||
// instead of a refusal every minute that Nextcloud's brute-force protection then throttles
|
||||
// (novox/hq issue 1253).
|
||||
const sharesBackoff = new Backoff(5 * 60_000, 60 * 60_000);
|
||||
tick((c) => pollSharesGuarded(c, sharesBackoff, pollShares));
|
||||
tick(applyOnlyOffice);
|
||||
console.log("[nextcloud] watching users, shares and onlyoffice settings");
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
},
|
||||
"user": {
|
||||
"type": "string",
|
||||
"description": "The Nextcloud account the module's tools act as; empty is admin.",
|
||||
"description": "The Nextcloud account the module's tools act as; empty is mesh-admin, the account the module makes and keeps for itself. Any other account is used as given and never repaired.",
|
||||
"trusted": true,
|
||||
"default": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// novox/hq issue 1253: the module's own Nextcloud account is made to match its own secret when
|
||||
// Nextcloud refuses the login, and a refusal that stays is said once and backed off, never asked
|
||||
// again every minute into Nextcloud's brute-force throttle. Run against the compiled module, the
|
||||
// way the runtime loads it.
|
||||
|
||||
import { test, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Backoff, LoginRefusedError, NextcloudClient, pollSharesGuarded } from "../dist/client.js";
|
||||
|
||||
const PASSWORD = "the-modules-own-secret";
|
||||
const REFUSED_XML = `<?xml version="1.0"?>\n<ocs>\n <meta>\n <status>failure</status>\n <statuscode>997</statuscode>\n <message>Unauthorised</message>\n </meta>\n <data/>\n</ocs>\n`;
|
||||
const SHARES = { ocs: { data: [{ id: 7, path: "/Photos", share_type: 3, share_with: "", uid_owner: "jochens" }] } };
|
||||
|
||||
function client(user = "mesh-admin"): any {
|
||||
return new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", user, PASSWORD);
|
||||
}
|
||||
|
||||
/** A stand-in Nextcloud: answers `statuses` in turn (the last one repeating) and counts the asks. */
|
||||
function standInFetch(statuses: number[]) {
|
||||
let asked = 0;
|
||||
const impl = async (): Promise<Response> => {
|
||||
const status = statuses[Math.min(asked, statuses.length - 1)];
|
||||
asked += 1;
|
||||
if (status === 200) return new Response(JSON.stringify(SHARES), { status });
|
||||
return new Response(REFUSED_XML, { status });
|
||||
};
|
||||
return { impl, asked: () => asked };
|
||||
}
|
||||
|
||||
/** A stand-in occ: knows whether the account exists, records every call and every env passed. */
|
||||
function standInOcc(accountExists: boolean, opts: { failWrite?: string } = {}) {
|
||||
const calls: string[][] = [];
|
||||
const envs: Array<Record<string, string> | undefined> = [];
|
||||
const impl = (args: string[], occOpts: { env?: Record<string, string> } = {}): string => {
|
||||
calls.push(args);
|
||||
envs.push(occOpts.env);
|
||||
if (args[0] === "user:info") {
|
||||
// As the real occ() throws it: occ's stdout as the message, its exit status alongside.
|
||||
if (!accountExists) throw Object.assign(new Error("user not found"), { status: 1 });
|
||||
return "{}";
|
||||
}
|
||||
if (["user:add", "user:resetpassword"].includes(args[0])) {
|
||||
if (opts.failWrite) throw new Error(opts.failWrite);
|
||||
return "";
|
||||
}
|
||||
throw new Error("unexpected occ call: " + args.join(" "));
|
||||
};
|
||||
return { calls, envs, impl };
|
||||
}
|
||||
|
||||
/** The occ calls that write: anything but a read of the account. */
|
||||
const writes = (calls: string[][]) => calls.filter((a) => a[0] !== "user:info");
|
||||
|
||||
// 1253/AC1: given an adopted instance without the mesh's account, when Nextcloud refuses the
|
||||
// login, then the account is added (no admin group) with the module's own secret, by OC_PASS and
|
||||
// never on an argv, and the shares are listed; Nextcloud's brute-force record is left alone.
|
||||
test("a refused login adds the missing account, password by environment, and lists the shares", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(false);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const web = standInFetch([401, 200]);
|
||||
const f = mock.method(globalThis, "fetch", web.impl);
|
||||
try {
|
||||
const shares = await c.listShares();
|
||||
assert.equal(web.asked(), 2);
|
||||
assert.deepEqual(shares.map((s: any) => s.id), ["7"]);
|
||||
const add = occ.calls.find((a) => a[0] === "user:add");
|
||||
assert.ok(add, "user:add ran");
|
||||
assert.ok(add.includes("--password-from-env") && add.includes("mesh-admin"));
|
||||
assert.ok(!add.some((a) => a.includes("group")), "no admin group: least privilege");
|
||||
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:add"]);
|
||||
assert.equal(occ.envs[occ.calls.indexOf(add)]?.OC_PASS, PASSWORD);
|
||||
assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)), "the password is on no argv");
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC2: given the account exists with another password, when Nextcloud refuses the login,
|
||||
// then its password is set to the module's own secret, in no group.
|
||||
test("a refused login resets an existing account's password, in no group", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl);
|
||||
try {
|
||||
await c.listShares();
|
||||
assert.deepEqual(occ.calls.map((a) => a[0]), ["user:info", "user:resetpassword"]);
|
||||
assert.equal(occ.envs[1]?.OC_PASS, PASSWORD);
|
||||
assert.ok(!occ.calls.flat().some((a) => a.includes(PASSWORD)));
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC3: given the login is still refused after the account was made right, then
|
||||
// listShares throws LoginRefusedError naming the status and the account, never the password or
|
||||
// the whole XML, and a later call does not repair the account again.
|
||||
test("a refusal after the repair throws LoginRefusedError once-repaired, short and without the password", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const web = standInFetch([401]);
|
||||
const f = mock.method(globalThis, "fetch", web.impl);
|
||||
try {
|
||||
await assert.rejects(c.listShares(), (err: any) => {
|
||||
assert.ok(err instanceof LoginRefusedError);
|
||||
assert.equal(err.status, 401);
|
||||
assert.equal(err.message, "Nextcloud refused the login of mesh-admin (401: Unauthorised)");
|
||||
assert.ok(!err.message.includes(PASSWORD));
|
||||
return true;
|
||||
});
|
||||
await assert.rejects(c.listShares(), LoginRefusedError);
|
||||
assert.equal(occ.calls.filter((a) => a[0] === "user:resetpassword").length, 1, "repaired once");
|
||||
assert.equal(web.asked(), 3);
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC3b: a throttle (429) is no evidence the password is wrong: two cycles of 429 then 200
|
||||
// run no occ write, and the 429s are LoginRefusedError for the back-off.
|
||||
test("a throttle (429) is backed off, never repaired", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([429, 200, 429, 200]).impl);
|
||||
try {
|
||||
for (let cycle = 0; cycle < 2; cycle++) {
|
||||
await assert.rejects(c.listShares(), (err: any) => err instanceof LoginRefusedError && err.status === 429);
|
||||
assert.equal((await c.listShares()).length, 1);
|
||||
}
|
||||
assert.deepEqual(occ.calls, [], "no occ call at all, so no write");
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC3c: a 401 runs exactly one repair.
|
||||
test("a 401 runs exactly one repair", async () => {
|
||||
const c = client();
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([401, 200]).impl);
|
||||
try {
|
||||
await c.listShares();
|
||||
assert.equal(writes(occ.calls).length, 1);
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC4: given the login stays refused, when the loop ticks every minute, then the refusal
|
||||
// is said once, the shares are asked only after 5, 10, 20, 40 and then every 60 minutes, the
|
||||
// ceiling is said once more, and one line says when the login is accepted again.
|
||||
test("a refusal that stays is said once, backed off to an hour, and its end is said", async () => {
|
||||
const said: string[] = [];
|
||||
const err = mock.method(console, "error", (...a: unknown[]) => { said.push(a.join(" ")); });
|
||||
const log = mock.method(console, "log", (...a: unknown[]) => { said.push(a.join(" ")); });
|
||||
try {
|
||||
let refused = true;
|
||||
let polls = 0;
|
||||
const askedAt: number[] = [];
|
||||
let clock = 0;
|
||||
const poll = async (): Promise<void> => {
|
||||
polls += 1;
|
||||
askedAt.push(clock / 60_000);
|
||||
if (refused) throw new LoginRefusedError(401, "mesh-admin", "Unauthorised");
|
||||
};
|
||||
const backoff = new Backoff(5 * 60_000, 60 * 60_000);
|
||||
for (let minute = 0; minute <= 200; minute++) {
|
||||
clock = minute * 60_000;
|
||||
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
||||
}
|
||||
assert.deepEqual(askedAt, [0, 5, 15, 35, 75, 135, 195]);
|
||||
assert.equal(said.length, 2, said.join("\n"));
|
||||
assert.match(said[0], /refused the login of mesh-admin \(401: Unauthorised\); not asking again for 5 min/);
|
||||
assert.match(said[1], /still; asking once an hour/);
|
||||
|
||||
refused = false;
|
||||
clock = 260 * 60_000;
|
||||
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
||||
assert.equal(said[2], "[nextcloud] shares: the login is accepted again");
|
||||
const before = polls;
|
||||
clock += 60_000;
|
||||
await pollSharesGuarded({} as any, backoff, poll, () => clock);
|
||||
assert.equal(polls, before + 1, "every tick again once it passes");
|
||||
assert.equal(said.length, 3);
|
||||
} finally {
|
||||
err.mock.restore();
|
||||
log.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC5: occ passes an environment variable by name alone, its value through the docker
|
||||
// CLI's environment, so the password is on no argv.
|
||||
test("occ passes OC_PASS by name, its value only in docker's environment", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-"));
|
||||
const record = join(dir, "record");
|
||||
writeFileSync(join(dir, "docker"), `#!/bin/sh\nprintf '%s\\n' "$*" > ${record}\nprintf '%s' "$OC_PASS" >> ${record}\n`);
|
||||
chmodSync(join(dir, "docker"), 0o755);
|
||||
const path = process.env.PATH;
|
||||
process.env.PATH = `${dir}:${path}`;
|
||||
try {
|
||||
client().occ(["user:resetpassword", "--password-from-env", "mesh-admin"], { env: { OC_PASS: PASSWORD } });
|
||||
} finally {
|
||||
process.env.PATH = path;
|
||||
}
|
||||
const [argv, env] = readFileSync(record, "utf8").split("\n");
|
||||
assert.equal(argv, "exec -e OC_PASS -u www-data nextcloud php occ user:resetpassword --password-from-env mesh-admin");
|
||||
assert.equal(env, PASSWORD);
|
||||
});
|
||||
|
||||
// 1253/AC6: an empty setting is the manifest's default, never an empty account name.
|
||||
test("an empty user setting falls back to the manifest's account", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-"));
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify({ user: "", container: "", url: "" }));
|
||||
writeFileSync(join(dir, "admin.secret"), PASSWORD);
|
||||
const c: any = NextcloudClient.fromEnv({
|
||||
MESH_NEXTCLOUD_CONFIG_FILE: join(dir, "config.json"),
|
||||
MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE: join(dir, "admin.secret"),
|
||||
MESH_NEXTCLOUD_ADMIN_USER: "mesh-admin",
|
||||
MESH_NEXTCLOUD_URL: "http://127.0.0.1:20003",
|
||||
});
|
||||
assert.equal(c.adminUser, "mesh-admin");
|
||||
assert.equal(c.container, "nextcloud");
|
||||
assert.equal(c.ocsUrl, "http://127.0.0.1:20003");
|
||||
});
|
||||
|
||||
// 1253/AC7: the repair touches only the module's own account: a refusal under a user setting
|
||||
// naming another account (the operator's) runs no occ write and throws LoginRefusedError.
|
||||
test("a refusal under another account's name writes nothing", async () => {
|
||||
const c = client("jochens");
|
||||
const occ = standInOcc(true);
|
||||
mock.method(c, "occ", occ.impl);
|
||||
const web = standInFetch([401]);
|
||||
const f = mock.method(globalThis, "fetch", web.impl);
|
||||
try {
|
||||
await assert.rejects(c.listShares(), (err: any) => {
|
||||
assert.ok(err instanceof LoginRefusedError);
|
||||
assert.match(err.message, /refused the login of jochens .*not repaired: only mesh-admin/);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(writes(occ.calls), []);
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// 1253/AC8: a repair whose occ write fails is said as a LoginRefusedError carrying occ's own
|
||||
// message, so the back-off's one loud line says why.
|
||||
test("a failing repair carries occ's message into LoginRefusedError", async () => {
|
||||
const c = client();
|
||||
mock.method(c, "occ", standInOcc(false, { failWrite: "The password policy refused the password" }).impl);
|
||||
const f = mock.method(globalThis, "fetch", standInFetch([401]).impl);
|
||||
try {
|
||||
await assert.rejects(c.listShares(), (err: any) =>
|
||||
err instanceof LoginRefusedError && /repair failed: The password policy refused the password/.test(err.message));
|
||||
} finally {
|
||||
f.mock.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** A fake docker on PATH: answers occ like Nextcloud (user:info's refusal on stdout, exit 1). */
|
||||
function fakeDocker(): { dir: string; record: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "nextcloud-1253-docker-"));
|
||||
const record = join(dir, "record");
|
||||
writeFileSync(join(dir, "docker"), [
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\n' "$*" >> ${record}`,
|
||||
'case "$*" in',
|
||||
" *' occ user:info '*) echo 'user not found'; exit 1 ;;",
|
||||
"esac",
|
||||
"exit 0",
|
||||
"",
|
||||
].join("\n"));
|
||||
chmodSync(join(dir, "docker"), 0o755);
|
||||
return { dir, record };
|
||||
}
|
||||
|
||||
function withPath<T>(dir: string, fn: () => T): T {
|
||||
const path = process.env.PATH;
|
||||
process.env.PATH = `${dir}:${path}`;
|
||||
try { return fn(); } finally { process.env.PATH = path; }
|
||||
}
|
||||
|
||||
// 1253/AC9: occ's own refusal on stdout with an empty stderr reaches the error, and the real
|
||||
// occ() through a docker answering like Nextcloud makes the missing account.
|
||||
test("occ says a refusal printed on stdout, and the real occ makes the missing account", () => {
|
||||
const docker = fakeDocker();
|
||||
const c = new (NextcloudClient as any)("nextcloud", "http://127.0.0.1:20003", "mesh-admin", PASSWORD);
|
||||
withPath(docker.dir, () => {
|
||||
assert.throws(() => c.occ(["user:info", "mesh-admin", "--output=json"]), /user not found/);
|
||||
const did = c.ensureAdminAccount();
|
||||
assert.equal(did, "created");
|
||||
});
|
||||
const lines = readFileSync(docker.record, "utf8").trim().split("\n");
|
||||
assert.ok(lines.some((l) => l.endsWith("php occ user:add --password-from-env --display-name=Mesh administration mesh-admin")), lines.join("\n"));
|
||||
assert.ok(!lines.some((l) => l.includes("bruteforce")), "the brute-force record is left alone");
|
||||
assert.ok(!lines.join("\n").includes(PASSWORD));
|
||||
});
|
||||
Reference in New Issue
Block a user