Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584dc57b9 | ||
|
|
e1e5a8ff0d | ||
|
|
c85472285d | ||
|
|
f0fbe193fa | ||
|
|
26908a8950 | ||
|
|
27a7757b97 | ||
|
|
87fe2c45cf | ||
|
|
24b244bc88 | ||
|
|
5baf8ad1d7 | ||
|
|
8da33e43d4 | ||
|
|
52de4c320e | ||
|
|
5e516317e9 | ||
|
|
6b6d5cba71 | ||
|
|
8393e29621 | ||
|
|
61563abf2f | ||
|
|
071c6885d9 | ||
|
|
8c5f27627b | ||
|
|
82bd6f49ff | ||
|
|
ceb3aac9b3 | ||
|
|
98c66fb1e4 | ||
|
|
07ec186031 | ||
|
|
2aecbe8055 | ||
|
|
3def293d94 | ||
|
|
9a8e54d988 | ||
|
|
2da9329df4 | ||
|
|
ecbee53cfd | ||
|
|
dcdf4238c7 | ||
|
|
d3423f54ed | ||
|
|
0e322d087f | ||
|
|
0300a6fd99 | ||
|
|
d21b81b00a | ||
|
|
b1e610b213 | ||
|
|
0134697f97 | ||
|
|
21583d14ad | ||
|
|
a4f5df797b | ||
|
|
ac2c56f2a9 | ||
|
|
b4a7bb9dea | ||
|
|
64b9ded2db | ||
|
|
67d6a5a7c1 | ||
|
|
5fe5f26e7e | ||
|
|
b3221b6ab8 | ||
|
|
0e7e220d49 | ||
|
|
879600202a | ||
|
|
565d0a848a | ||
|
|
71e094b2de | ||
|
|
f96704fa0f | ||
|
|
86fdf74a0a | ||
|
|
843281c0e5 | ||
|
|
e9279ce3c2 | ||
|
|
c9414b1c98 | ||
|
|
ea53b2f920 | ||
|
|
99cc073e2a | ||
|
|
b94150fcdb | ||
|
|
a01715ddf5 | ||
|
|
e2136ec0c9 | ||
|
|
a30c168512 | ||
|
|
f885250913 | ||
|
|
7627503d9d | ||
|
|
351c7c4bf4 | ||
|
|
55a26b22cf | ||
|
|
100e74f283 | ||
|
|
c8e1e2b2fa | ||
|
|
d0d00f922a | ||
|
|
c2bd7760b2 | ||
|
|
f0fd53281b | ||
|
|
fb039250f6 | ||
|
|
447e6a13cd | ||
|
|
c51a46b802 | ||
|
|
15a5fe09b6 | ||
|
|
3ded9e7800 | ||
|
|
e7aaa8420e | ||
|
|
22381d7522 | ||
|
|
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 | ||
|
|
68f7431d98 | ||
|
|
53749833bf | ||
|
|
2167b95a36 | ||
|
|
d09cbf1523 | ||
|
|
ac6712cbb5 | ||
|
|
5b32342ac2 | ||
|
|
b67b5af5e6 | ||
|
|
d700ceca20 | ||
|
|
3a1017f559 | ||
|
|
a17d0c8e93 | ||
|
|
9fa6710296 | ||
|
|
05f849979c | ||
|
|
4dee77df23 | ||
|
|
8a79877a15 | ||
|
|
2139e83a8a | ||
|
|
e7ca8f7af1 | ||
|
|
eb79996336 | ||
|
|
1a271a1fb1 | ||
|
|
30d04f6bef | ||
|
|
5ff337e0dc |
@@ -29,3 +29,8 @@ modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
|
||||
modules/mesh-config/mesh-config
|
||||
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
*.test
|
||||
|
||||
# binaries a local go build leaves in a module root
|
||||
/checks/paths/paths
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
|
||||
@@ -15,6 +15,13 @@ package main
|
||||
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
|
||||
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
|
||||
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
|
||||
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
|
||||
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
|
||||
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
|
||||
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
|
||||
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
|
||||
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
|
||||
// where those are shown; the log says so when they are held.
|
||||
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
|
||||
// status. A message no machine took is said to the router as failed, and tried again by it.
|
||||
|
||||
@@ -93,6 +100,9 @@ type Desk struct {
|
||||
pause map[string]pauseSeen
|
||||
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
|
||||
lost map[string]time.Time
|
||||
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
|
||||
// before the store, and kept at the next piece of work.
|
||||
unkept map[string]Shown
|
||||
}
|
||||
|
||||
func (d *Desk) init() {
|
||||
@@ -107,6 +117,9 @@ func (d *Desk) init() {
|
||||
if d.lost == nil {
|
||||
d.lost = map[string]time.Time{}
|
||||
}
|
||||
if d.unkept == nil {
|
||||
d.unkept = map[string]Shown{}
|
||||
}
|
||||
if d.Now == nil {
|
||||
d.Now = time.Now
|
||||
}
|
||||
@@ -138,6 +151,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
|
||||
return nil
|
||||
}
|
||||
d.KeepUnkept()
|
||||
machines := d.Machines()
|
||||
if m.Secret {
|
||||
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
|
||||
@@ -167,12 +181,16 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
switch {
|
||||
case edit:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
return err
|
||||
}
|
||||
if before == nil {
|
||||
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
|
||||
"a notification it was to replace, if any, stays", m.Handle)
|
||||
}
|
||||
case !m.Secret:
|
||||
var err error
|
||||
if before, err = d.Store.Get(m.Handle); err != nil {
|
||||
if before, err = d.recall(m.Handle); err != nil {
|
||||
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
|
||||
before = nil
|
||||
}
|
||||
@@ -218,12 +236,91 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
|
||||
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
|
||||
return nil
|
||||
}
|
||||
if err := d.Store.Put(m.Handle, s); err != nil {
|
||||
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
|
||||
}
|
||||
d.keep(m.Handle, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
|
||||
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
|
||||
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
|
||||
func (d *Desk) keep(handle string, s Shown) {
|
||||
err := d.Store.Put(handle, s)
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err == nil {
|
||||
delete(d.unkept, handle)
|
||||
return
|
||||
}
|
||||
d.unkept[handle] = s
|
||||
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
|
||||
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
|
||||
err, len(d.unkept))
|
||||
}
|
||||
|
||||
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
|
||||
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
|
||||
func (d *Desk) KeepUnkept() {
|
||||
d.init()
|
||||
d.mu.Lock()
|
||||
held := make(map[string]Shown, len(d.unkept))
|
||||
for h, s := range d.unkept {
|
||||
held[h] = s
|
||||
}
|
||||
d.mu.Unlock()
|
||||
for h, s := range held {
|
||||
if err := d.Store.Put(h, s); err != nil {
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
|
||||
delete(d.unkept, h)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.Logf("[desk-channel] where %s is shown is kept now", h)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
s, ok := d.unkept[handle]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
|
||||
func (d *Desk) recall(handle string) (*Shown, error) {
|
||||
if s, ok := d.unkeptOf(handle); ok {
|
||||
return &s, nil
|
||||
}
|
||||
return d.Store.Get(handle)
|
||||
}
|
||||
|
||||
// handles is every handle shown: the store's and those held in memory.
|
||||
func (d *Desk) handles() ([]string, error) {
|
||||
hs, err := d.Store.Handles()
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
seen := map[string]bool{}
|
||||
for _, h := range hs {
|
||||
seen[h] = true
|
||||
}
|
||||
for h := range d.unkept {
|
||||
if !seen[h] {
|
||||
hs = append(hs, h)
|
||||
}
|
||||
}
|
||||
if err != nil && len(d.unkept) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return hs, nil
|
||||
}
|
||||
|
||||
func sameShown(a, b Shown) bool {
|
||||
x, err1 := json.Marshal(a)
|
||||
y, err2 := json.Marshal(b)
|
||||
return err1 == nil && err2 == nil && string(x) == string(y)
|
||||
}
|
||||
|
||||
// show puts one message on one machine's notifier, in place of `replace` when it names one.
|
||||
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
|
||||
urgency := "normal"
|
||||
@@ -308,12 +405,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
|
||||
}
|
||||
|
||||
func (d *Desk) find(ticket string) (string, *Shown) {
|
||||
handles, err := d.Store.Handles()
|
||||
handles, err := d.handles()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
for _, h := range handles {
|
||||
s, err := d.Store.Get(h)
|
||||
s, err := d.recall(h)
|
||||
if err != nil || s == nil {
|
||||
continue
|
||||
}
|
||||
@@ -356,9 +453,9 @@ func (d *Desk) Started(event, machine string) int {
|
||||
d.lost[machine] = d.Now()
|
||||
d.mu.Unlock()
|
||||
n := 0
|
||||
if handles, err := d.Store.Handles(); err == nil {
|
||||
if handles, err := d.handles(); err == nil {
|
||||
for _, h := range handles {
|
||||
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
|
||||
if _, here := s.On[machine]; here {
|
||||
n++
|
||||
}
|
||||
@@ -472,6 +569,7 @@ func (d *Desk) Status() map[string]any {
|
||||
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
|
||||
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
|
||||
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
|
||||
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
|
||||
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
|
||||
}}
|
||||
if s.Why != "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -363,9 +364,64 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
|
||||
type downStore struct {
|
||||
memShown
|
||||
down *bool
|
||||
}
|
||||
|
||||
func (s downStore) Get(h string) (*Shown, error) {
|
||||
if *s.down {
|
||||
return nil, errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Get(h)
|
||||
}
|
||||
func (s downStore) Put(h string, v Shown) error {
|
||||
if *s.down {
|
||||
return errors.New("nats: no responders available for request")
|
||||
}
|
||||
return s.memShown.Put(h, v)
|
||||
}
|
||||
|
||||
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
|
||||
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
|
||||
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
|
||||
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
|
||||
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
|
||||
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
|
||||
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
|
||||
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
|
||||
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
|
||||
for _, back := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
|
||||
d, shown, _, store := newDesk(t, "laptop")
|
||||
down := true
|
||||
d.Store = downStore{store, &down}
|
||||
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
|
||||
t.Fatalf("the raise was not taken: %v", err)
|
||||
}
|
||||
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
|
||||
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
|
||||
t.Fatalf("the clearing was not taken: %v", err)
|
||||
}
|
||||
d.KeepUnkept() // the minute's standing, store still down
|
||||
down = !back
|
||||
d.KeepUnkept()
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
|
||||
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
|
||||
}
|
||||
s, kept := store["m9"]
|
||||
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
|
||||
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
|
||||
}
|
||||
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
|
||||
t.Fatalf("recalled %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
d, shown, said, store := newDesk(t, "laptop", "desktop")
|
||||
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
|
||||
first := store["m1"].On
|
||||
|
||||
@@ -100,6 +100,7 @@ func run(d *Desk) {
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
d.KeepUnkept()
|
||||
d.SayStanding()
|
||||
time.Sleep(time.Minute)
|
||||
}
|
||||
|
||||
@@ -275,6 +275,53 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:`
|
||||
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
|
||||
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
|
||||
|
||||
## The mesh's accounts read every repository (the operator, 2026-10-26)
|
||||
|
||||
The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox`
|
||||
through its team; the build modules fetch over ssh as `nox-builder` (ADR 0466 §3), in a team that reads the
|
||||
organisation's code; the machines' agents use `nox-agent` (ADR 0410 §1). Any other repository each saw only when public,
|
||||
so a private repository of a user answered every tool 404 and every build's fetch "not authorized to read". `readall.ts`
|
||||
makes each of these accounts (one list, `meshAccounts` in `tools-account.ts`, whose two Go names
|
||||
`internal/forgeaccount`'s test holds to its own constants) a collaborator that **reads only** on each repository it
|
||||
cannot read: one repository at a time, no organisation membership, the least the forge offers. An account the forge
|
||||
does not hold yet (the forge-account provider makes `nox-builder` and `nox-agent` when their first consumer asks) is
|
||||
named and asked nothing; the start's pass is run again ten minutes apart until every account was there once.
|
||||
|
||||
- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is
|
||||
asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and
|
||||
`forge.fork` at once, and the first event of any repository this process has not seen readable (the forge may not
|
||||
send a creation for every owner). Nothing polls for it.
|
||||
- **Not caught:** a repository made private after it was seen readable. The forge sends no event for a change of
|
||||
visibility, and the start's pass already saw it readable, so its events ask nothing. It is granted at the module's
|
||||
next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. A repository whose permission
|
||||
the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on.
|
||||
- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a
|
||||
repository's administration, which on another user's repository only the site administrator holds. It is used for
|
||||
five calls alone: list every repository, read one, whether an account exists, read an account's permission on one,
|
||||
add an account as a read collaborator. The tools' own calls keep their token.
|
||||
- **Tools:** `gitea_unreadable_repos` (per account, every repository it cannot read, the permission the forge gives
|
||||
it there and why, a count per account and the accounts the forge does not hold) and `gitea_grant_read` (the same
|
||||
grant by hand, now, on every repository or on those named, for every account or those named; each grant read back
|
||||
from the forge, a refusal answered in the forge's own words).
|
||||
|
||||
### The merging account writes the repositories the mesh delivers
|
||||
|
||||
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
|
||||
a user's repository could be checked and asked but never merged (issue 1945: car-hunter #2's status refused 403 after
|
||||
the operator's Approve). `writes.ts` makes it a collaborator that **writes** on each repository that builds a registered
|
||||
module, and on no other (the operator, 2026-10-26): the build modules' and the agents' accounts keep read.
|
||||
|
||||
- **Which:** the indexer's state `mesh-delivery-indexer.modules` (in this module's `reads`), one key per registered
|
||||
module, each naming its repository. A repository of `novox` needs nothing: the team writes it. Every other repository
|
||||
of a user stays read-only.
|
||||
- **When:** the state's watch hands every module at the start and each one registered after; a repository not yet
|
||||
written is granted then, read back from the forge, and said on the bus as `gitea.repo.writable` {owner, repo,
|
||||
account}, on which mesh-delivery-gate reads that repository again. Nothing polls.
|
||||
- **Tools:** `gitea_unwritable_repos` (every delivered repository the account cannot write, its permission and why)
|
||||
and `gitea_grant_write` (the same grant by hand, on every delivered repository or those named; what it granted, what
|
||||
was written already, what the forge refused in its own words, and the repositories left read-only and why).
|
||||
`gitea_commit_statuses` answers `merge.writes`, whether the account may write the repository at all.
|
||||
|
||||
## Files it does not know
|
||||
|
||||
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
|
||||
|
||||
+155
-5
@@ -50,6 +50,9 @@ export interface GiteaRepo {
|
||||
default_branch?: string;
|
||||
/** When anything last moved in it — a push, and so a merge. */
|
||||
updated_at?: string;
|
||||
/** Whether the account asking may write it (the forge's permissions.push or admin): a commit status and a merge need
|
||||
* it. Absent when the forge did not say. */
|
||||
writes?: boolean;
|
||||
}
|
||||
|
||||
/** An issue, with its labels flattened to names. */
|
||||
@@ -98,6 +101,10 @@ export interface GiteaPull {
|
||||
html_url: string;
|
||||
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
|
||||
body?: string;
|
||||
/** Whether the forge would merge it into its base now (gitea's `mergeable`): false while it is in conflict, while the
|
||||
* forge is still working that out after its base moved, or while it is a work in progress; absent when the forge did not
|
||||
* say. What a group's merge reads of every member before it merges any (novox/hq issue 1275). */
|
||||
mergeable?: boolean;
|
||||
}
|
||||
|
||||
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
|
||||
@@ -789,14 +796,29 @@ export class GiteaClient {
|
||||
|
||||
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
|
||||
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
|
||||
/** How long a merge waits before it asks again after the forge's 405 "Please try again later" (novox/hq issue 1275):
|
||||
* the forge says so while it works out whether the pull request merges, for a few seconds after its base moved. */
|
||||
static mergeRetryAfter: number[] = [1000, 2000, 4000];
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
|
||||
headCommit?: string): Promise<void> {
|
||||
indexOf(index);
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// Only the forge's transient answer is asked again, briefly; every other refusal, and this one past the last
|
||||
// wait, is the caller's to say. head_commit_id keeps a head that moved in between from landing.
|
||||
const wait = GiteaClient.mergeRetryAfter[attempt];
|
||||
if (wait === undefined || !tryAgainLater(err)) throw err;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
|
||||
@@ -812,6 +834,8 @@ export class GiteaClient {
|
||||
html_url: r.html_url,
|
||||
default_branch: r.default_branch,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
...(r.permissions && typeof r.permissions === "object"
|
||||
? { writes: r.permissions.push === true || r.permissions.admin === true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -842,6 +866,7 @@ export class GiteaClient {
|
||||
updated_at: p.updated_at ?? undefined,
|
||||
html_url: p.html_url,
|
||||
body: typeof p.body === "string" ? p.body : undefined,
|
||||
mergeable: typeof p.mergeable === "boolean" && !p.merged ? p.mergeable : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -853,6 +878,25 @@ interface AdminResponse {
|
||||
readonly body: any;
|
||||
}
|
||||
|
||||
/** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */
|
||||
export interface ForgeRepository {
|
||||
full_name: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
private: boolean;
|
||||
archived: boolean;
|
||||
/** The owner's own visibility: public, limited or private. A public repository of an owner not public is hidden
|
||||
* from whoever is no member. */
|
||||
owner_visibility: string;
|
||||
}
|
||||
|
||||
function forgeRepository(r: any): ForgeRepository {
|
||||
const owner = String(r?.owner?.login ?? r?.owner?.username ?? "");
|
||||
const name = String(r?.name ?? "");
|
||||
return { full_name: String(r?.full_name ?? `${owner}/${name}`), owner, name, private: r?.private === true,
|
||||
archived: r?.archived === true, owner_visibility: String(r?.owner?.visibility ?? "public") };
|
||||
}
|
||||
|
||||
/**
|
||||
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
|
||||
* by the provisioner (novox/hq ADR 0048/0076).
|
||||
@@ -970,6 +1014,105 @@ export class GiteaAdmin {
|
||||
return repos;
|
||||
}
|
||||
|
||||
// ---- The tools' account reads every repository (readall.ts) ----
|
||||
|
||||
/** Every repository on the forge, of every user and organisation: the search as the site administrator, whom the
|
||||
* forge answers every repository, private ones and those of private owners included. Only a read. */
|
||||
async everyRepository(): Promise<ForgeRepository[]> {
|
||||
const all: ForgeRepository[] = [];
|
||||
for (let page = 1; page <= 200; page++) {
|
||||
const res = await this.request(`/repos/search?limit=50&page=${page}`);
|
||||
if (res.status !== 200) GiteaAdmin.fail("/repos/search", res);
|
||||
const list: any[] = Array.isArray(res.body?.data) ? res.body.data : [];
|
||||
all.push(...list.map(forgeRepository));
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** One repository as the site administrator sees it, or null when the forge holds none by that name. */
|
||||
async repository(owner: string, repo: string): Promise<ForgeRepository | null> {
|
||||
const res = await this.request(repoPath(owner, repo));
|
||||
if (res.status === 404) return null;
|
||||
if (res.status !== 200) GiteaAdmin.fail(repoPath(owner, repo), res);
|
||||
return forgeRepository(res.body);
|
||||
}
|
||||
|
||||
/** Whether the forge holds an account of this name. */
|
||||
async accountExists(user: string): Promise<boolean> {
|
||||
const path = `/users/${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status === 404) return false;
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
|
||||
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
|
||||
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
|
||||
* account that writes through a team (novox/hq issue 1945). */
|
||||
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
|
||||
const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path);
|
||||
if (res.status === 404) return "none";
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
const p = res.body?.permissions;
|
||||
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
|
||||
if (p?.push === true) return "write";
|
||||
return p?.pull === true ? "read" : "none";
|
||||
}
|
||||
|
||||
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
|
||||
* it, which on a repository of another user only the site administrator is. Idempotent: the forge answers 204 again. */
|
||||
async addReadCollaborator(owner: string, repo: string, user: string): Promise<void> {
|
||||
await this.addCollaborator(owner, repo, user, "read");
|
||||
}
|
||||
|
||||
/** Make an account a collaborator of a repository that writes: pushes a branch, sets a commit status, merges (writes.ts:
|
||||
* only on a repository that builds a registered module). An existing read collaborator is changed to write. */
|
||||
async addWriteCollaborator(owner: string, repo: string, user: string): Promise<void> {
|
||||
await this.addCollaborator(owner, repo, user, "write");
|
||||
}
|
||||
|
||||
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
|
||||
* rule covering main; null when none does. With the administrator's login. */
|
||||
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
|
||||
const rules = await this.branchProtections(owner, repo);
|
||||
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
|
||||
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
|
||||
}
|
||||
|
||||
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
|
||||
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
|
||||
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
|
||||
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
|
||||
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
|
||||
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
|
||||
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
|
||||
const rule = await this.mainRule(owner, repo);
|
||||
const listed = rule?.merge_whitelist_usernames ?? [];
|
||||
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
|
||||
const name = String(rule.rule_name ?? rule.branch_name);
|
||||
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
|
||||
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
|
||||
if (res.status !== 200) GiteaAdmin.fail(path, res);
|
||||
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
|
||||
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
|
||||
const held = after.merge_whitelist_usernames ?? [];
|
||||
if (after.enable_merge_whitelist === true && !held.includes(account)) {
|
||||
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
|
||||
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
|
||||
}
|
||||
return { changed: true, rule: after };
|
||||
}
|
||||
|
||||
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
|
||||
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
|
||||
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
|
||||
if (res.status === 204 || res.status === 200) return;
|
||||
GiteaAdmin.fail(path, res);
|
||||
}
|
||||
|
||||
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
|
||||
* create won the race) are all success. */
|
||||
async ensureOrg(name: string): Promise<void> {
|
||||
@@ -1160,3 +1303,10 @@ export function directoriesAbove(paths: string[]): string[] {
|
||||
function encodePath(path: string): string {
|
||||
return pathSegments("file path", path);
|
||||
}
|
||||
|
||||
/** Whether an error is the forge's 405 "Please try again later" on a merge: its answer while the pull request's
|
||||
* mergeability is being worked out (or it is in conflict, which a later ask says the same way). */
|
||||
export function tryAgainLater(err: unknown): boolean {
|
||||
const words = err instanceof Error ? err.message : String(err);
|
||||
return / 405 /.test(words) && /try again later/i.test(words);
|
||||
}
|
||||
|
||||
+50
-3
@@ -58,8 +58,11 @@ import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGa
|
||||
import { GateWriter } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
import { bindInBackground } from "./bind.js";
|
||||
import { bindInBackground, bindUntilHeld } from "./bind.js";
|
||||
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
|
||||
import { ReadAll, readAllTools } from "./readall.js";
|
||||
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||
@@ -500,8 +503,21 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
|
||||
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
|
||||
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
|
||||
|
||||
// **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
|
||||
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
|
||||
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
|
||||
// first use, so a module whose admin login is missing still serves the rest and the tools say why.
|
||||
let readAllOnce: ReadAll | null = null;
|
||||
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
|
||||
|
||||
// **The merging account writes every repository that builds a registered module** (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll.
|
||||
let writesOnce: DeliveredWrites | null = null;
|
||||
const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(),
|
||||
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body)));
|
||||
|
||||
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
|
||||
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...(gitea ? [commitStatusTool({
|
||||
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
@@ -543,7 +559,12 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
|
||||
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
|
||||
refused: (reason, from) => pace.refused(reason, from),
|
||||
publish: (event) => emit(event.name, event.body),
|
||||
act: (event) => actOn(client, event) });
|
||||
act: async (event) => {
|
||||
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
|
||||
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
|
||||
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
|
||||
await actOn(client, event);
|
||||
} });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
@@ -594,6 +615,32 @@ if (gitea) {
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
|
||||
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
|
||||
void listenForForge(client);
|
||||
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
|
||||
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
|
||||
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
|
||||
// their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
|
||||
// until every account was granted once; the line of each pass names it.
|
||||
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
|
||||
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
|
||||
}, (err) => {
|
||||
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
|
||||
setTimeout(readAllAtStart, 60_000);
|
||||
});
|
||||
try {
|
||||
readAll();
|
||||
readAllAtStart();
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a
|
||||
// repository that builds a registered module is made writable at the start and when its first module is registered
|
||||
// (writes.ts). A read not granted yet is said and asked again until it holds.
|
||||
try {
|
||||
const w = writes();
|
||||
void bindUntilHeld(INDEXER_MODULES, () => state<unknown>(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange)));
|
||||
} catch (err) {
|
||||
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
|
||||
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
|
||||
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package forgeaccount
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
|
||||
if got := ForgeUser("g14", "machine"); got != "nox-agent" {
|
||||
@@ -15,6 +19,18 @@ func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
|
||||
if got := NodeAccount("g14"); got != "agent-g14" {
|
||||
t.Fatalf("the token tool's per-node account = %q", got)
|
||||
}
|
||||
// The accounts that read every repository (readall.ts, meshAccounts in tools-account.ts) name these two users: the
|
||||
// list is held to the Go constants here, so the two cannot drift apart (the operator, 2026-10-26).
|
||||
ts, err := os.ReadFile("../../tools-account.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, want := range map[string]string{"BUILDER_ACCOUNT": BuilderUser, "AGENT_ACCOUNT": AgentUser} {
|
||||
m := regexp.MustCompile(`export const ` + name + ` = "([^"]+)";`).FindSubmatch(ts)
|
||||
if m == nil || string(m[1]) != want {
|
||||
t.Errorf("tools-account.ts %s = %q, want %q (internal/forgeaccount)", name, m, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
],
|
||||
"emits": [
|
||||
"repo.created",
|
||||
"repo.writable",
|
||||
"issue.opened",
|
||||
"pull.merged",
|
||||
"pull.updated",
|
||||
@@ -181,6 +182,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 +323,8 @@
|
||||
"takes-config": [
|
||||
"url",
|
||||
"keep-main-rules",
|
||||
"main-mergers"
|
||||
"main-mergers",
|
||||
"check-gates-merge"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -560,6 +568,7 @@
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys",
|
||||
"mesh-build-image.keys",
|
||||
"mesh-delivery-checker.owed"
|
||||
"mesh-delivery-checker.owed",
|
||||
"mesh-delivery-indexer.modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
|
||||
// should get read access to all repositories"; and "why wouldn't the forge account get access to all repo's?").
|
||||
//
|
||||
// **Every account the mesh's parts use, from one list** (MESH_ACCOUNTS, tools-account.ts): the tools' `mesh-tools`, the
|
||||
// build modules' `nox-builder` (their fetch over ssh, ADR 0466 §3) and the machines' `nox-agent` (ssh-client's key,
|
||||
// ADR 0410 §1). The first pass granted the tools' account alone, so the build modules' fetch of jschoubben/car-hunter
|
||||
// still failed on ace and g14 ("nox-builder ... is not authorized to read"). The two Go names are the ones of
|
||||
// internal/forgeaccount, whose test holds the list to them. An account the forge does not hold yet (its consumer never
|
||||
// asked) is said and left out, never a refusal per repository.
|
||||
//
|
||||
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
|
||||
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
|
||||
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
|
||||
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
|
||||
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
|
||||
//
|
||||
// - **at the start**, one pass over every repository of every user and organisation;
|
||||
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
|
||||
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
|
||||
// send a repository's creation for every owner. Nothing polls for it.
|
||||
//
|
||||
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
|
||||
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
|
||||
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
|
||||
//
|
||||
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
|
||||
// which on another user's repository only the site administrator holds; the module holds that login for setting the
|
||||
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
|
||||
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
|
||||
// own calls, which keep their token.
|
||||
//
|
||||
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
|
||||
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
|
||||
// `gitea_grant_read` grants again now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
|
||||
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
||||
export interface ReadAllForge {
|
||||
everyRepository(): Promise<ForgeRepository[]>;
|
||||
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
|
||||
/** Whether the forge holds the account at all. */
|
||||
accountExists(user: string): Promise<boolean>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** A repository an account cannot read, and why. */
|
||||
export interface Unreadable {
|
||||
account: string;
|
||||
repository: string;
|
||||
permission: string;
|
||||
private: boolean;
|
||||
archived: boolean;
|
||||
why: string;
|
||||
}
|
||||
|
||||
export interface Granted {
|
||||
/** Repositories looked at (for each account). */
|
||||
checked: number;
|
||||
/** The accounts looked at: those the forge holds. */
|
||||
accounts: string[];
|
||||
/** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
|
||||
absent: string[];
|
||||
/** Made readable now, by a read collaborator: each repository and the account. */
|
||||
granted: { account: string; repository: string }[];
|
||||
/** The forge refused the grant: each repository, the account and the forge's own words. */
|
||||
refused: { account: string; repository: string; why: string }[];
|
||||
}
|
||||
|
||||
const READS = new Set(["read", "write", "admin", "owner"]);
|
||||
|
||||
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Why the account cannot read a repository, from what the forge says of it. */
|
||||
export function whyUnreadable(repo: ForgeRepository, account: string): string {
|
||||
const parts: string[] = [];
|
||||
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
|
||||
else if (repo.owner_visibility !== "public") {
|
||||
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
|
||||
} else parts.push(`the forge answers ${account} no read on it`);
|
||||
if (repo.archived) parts.push("archived");
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export class ReadAll {
|
||||
/** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
|
||||
* was seen reading asks nothing more. */
|
||||
readonly readable = new Map<string, Set<string>>();
|
||||
readonly accounts: readonly string[];
|
||||
|
||||
constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
|
||||
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
|
||||
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
|
||||
if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
|
||||
for (const a of this.accounts) this.readable.set(a, new Set());
|
||||
}
|
||||
|
||||
/** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
|
||||
private async present(only?: string[]): Promise<{ accounts: string[]; absent: string[] }> {
|
||||
const wanted = only && only.length ? only : [...this.accounts];
|
||||
for (const a of wanted) if (!this.accounts.includes(a)) throw new Error(`"${a}" is not one of the mesh's accounts: ${this.accounts.join(", ")}`);
|
||||
const accounts: string[] = [];
|
||||
const absent: string[] = [];
|
||||
for (const a of wanted) ((await this.forge.accountExists(a)) ? accounts : absent).push(a);
|
||||
return { accounts, absent };
|
||||
}
|
||||
|
||||
/** Every repository each account cannot read, and why, as the forge says it now. */
|
||||
async unreadable(only?: string[]): Promise<{ checked: number; accounts: { account: string; unreadable: number }[]; absent: string[]; unreadable: Unreadable[] }> {
|
||||
const { accounts, absent } = await this.present(only);
|
||||
const repos = await this.forge.everyRepository();
|
||||
const out: Unreadable[] = [];
|
||||
for (const account of accounts) {
|
||||
for (const repo of repos) {
|
||||
try {
|
||||
const u = await this.judge(repo, account);
|
||||
if (u) out.push(u);
|
||||
} catch (err) {
|
||||
// A permission the forge would not answer is said for that repository; the rest are still looked at.
|
||||
out.push({ account, repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
|
||||
why: `the forge did not answer ${account}'s permission on it — ${said(err)}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { checked: repos.length, accounts: accounts.map((account) => ({ account, unreadable: out.filter((u) => u.account === account).length })),
|
||||
absent, unreadable: out };
|
||||
}
|
||||
|
||||
/** Grant read on every repository an account cannot read (or on those named, `owner/name`), for every account of the
|
||||
* list the forge holds (or for those named). */
|
||||
async grant(only?: string[], accountsOnly?: string[]): Promise<Granted> {
|
||||
const { accounts, absent } = await this.present(accountsOnly);
|
||||
let repos: ForgeRepository[];
|
||||
if (only && only.length) {
|
||||
repos = [];
|
||||
for (const full of only) {
|
||||
const [owner, name, ...rest] = full.split("/");
|
||||
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
|
||||
const repo = await this.forge.repository(owner, name);
|
||||
if (!repo) throw new Error(`the forge holds no repository ${full}`);
|
||||
repos.push(repo);
|
||||
}
|
||||
} else repos = await this.forge.everyRepository();
|
||||
const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
|
||||
for (const account of accounts) {
|
||||
for (const repo of repos) {
|
||||
const repository = repo.full_name;
|
||||
let u: Unreadable | null;
|
||||
try {
|
||||
u = await this.judge(repo, account);
|
||||
} catch (err) {
|
||||
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
|
||||
result.refused.push({ account, repository, why: `its permission could not be read — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!u) continue;
|
||||
try {
|
||||
await this.forge.addReadCollaborator(repo.owner, repo.name, account);
|
||||
} catch (err) {
|
||||
result.refused.push({ account, repository, why: said(err) });
|
||||
continue;
|
||||
}
|
||||
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
||||
} catch (err) {
|
||||
result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (READS.has(after)) {
|
||||
this.readable.get(account)!.add(repository);
|
||||
result.granted.push({ account, repository });
|
||||
} else {
|
||||
result.refused.push({ account, repository, why: `the grant was answered, but the forge still gives ${account} "${after}"` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
|
||||
async atStart(): Promise<Granted> {
|
||||
const r = await this.grant();
|
||||
const per = r.accounts.map((a) => {
|
||||
const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
|
||||
return `${a} ${g.length} granted read now${g.length ? ` (${g.join(", ")})` : ""}`;
|
||||
});
|
||||
this.log(`the mesh's accounts read every repository: ${r.checked} looked at; ${per.join("; ")}` +
|
||||
`${r.absent.length ? `; not on the forge yet, so not granted: ${r.absent.join(", ")}` : ""}` +
|
||||
`${r.refused.length ? `; ${r.refused.length} refused: ` + r.refused.map((x) => `${x.account} on ${x.repository} — ${x.why}`).join("; ") : ""}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** A forge event: a repository made (or forked), or the first event of one some account was not seen reading, is
|
||||
* granted read now to every account that cannot read it. */
|
||||
async onEvent(event: { name: string; body: unknown }): Promise<void> {
|
||||
const b = (event.body ?? {}) as { owner?: string; repo?: string };
|
||||
if (!b.owner || !b.repo) return;
|
||||
const full = `${b.owner}/${b.repo}`;
|
||||
if (event.name === "forge.repository.deleted") {
|
||||
for (const seen of this.readable.values()) seen.delete(full);
|
||||
return;
|
||||
}
|
||||
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
|
||||
// Seen readable once by every account, its events ask nothing: so a repository made private after that is not
|
||||
// caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
|
||||
if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
|
||||
const r = await this.grant([full]);
|
||||
for (const x of r.granted) this.log(`${x.account} was given read on ${full}, on the forge's ${event.name}`);
|
||||
for (const x of r.refused) this.log(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
|
||||
}
|
||||
|
||||
private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
|
||||
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
|
||||
const seen = this.readable.get(account)!;
|
||||
if (READS.has(permission)) {
|
||||
seen.add(repo.full_name);
|
||||
return null;
|
||||
}
|
||||
seen.delete(repo.full_name);
|
||||
return { account, repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, account) };
|
||||
}
|
||||
}
|
||||
|
||||
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
||||
|
||||
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
|
||||
|
||||
/** The two tools: what each account cannot read and why, and the grant now. */
|
||||
export function readAllTools(readAll: () => ReadAll): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_unreadable_repos",
|
||||
description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
|
||||
"the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
|
||||
"For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
|
||||
"the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
|
||||
"account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
|
||||
"with the site administrator's login, which alone sees every repository.",
|
||||
input: {
|
||||
accounts: { type: "array", items: { type: "string" }, description: "the accounts to look at; empty or absent, every one of the mesh's" },
|
||||
},
|
||||
run: async (args) => readAll().unreadable(strings(args.accounts)),
|
||||
},
|
||||
{
|
||||
name: "gitea_grant_read",
|
||||
description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
|
||||
"repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
|
||||
"collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
|
||||
"forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
|
||||
"is not authorized to read a repository. Answers how many were looked at, which were granted to which account " +
|
||||
"(each read back from the forge), which the forge refused, in its own words, and the accounts it does not hold " +
|
||||
"yet. Needs the site administrator's login, used for this grant alone.",
|
||||
input: {
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" },
|
||||
accounts: { type: "array", items: { type: "string" }, description: "the accounts to grant; empty or absent, every one of the mesh's" },
|
||||
},
|
||||
run: async (args) => readAll().grant(strings(args.repositories), strings(args.accounts)),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
// **The forge's 405 "Please try again later" on a merge is asked again briefly** (novox/hq issue 1275, 2026-10-26 13:40):
|
||||
// mesh-delivery#73's merge was answered so a few seconds after its base moved, and counted as refused at once. The forge
|
||||
// says it while it works out whether the pull request merges; the merge is asked again after a short wait, and every other
|
||||
// refusal is answered at once. A pull request's own mergeable is carried, so a group can be read whole before it merges.
|
||||
|
||||
/** A forge whose merge of novox/hq#7 answers each of `answers` in turn (the last for ever), and whose pull request says
|
||||
* `mergeable`. */
|
||||
async function serve(answers: { status: number; body: string }[], mergeable?: boolean) {
|
||||
const { GiteaClient } = await import("../client.ts");
|
||||
let merges = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7/merge" && req.method === "POST") {
|
||||
const a = answers[Math.min(merges++, answers.length - 1)];
|
||||
res.statusCode = a.status;
|
||||
return res.end(a.body);
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7") {
|
||||
return res.end(JSON.stringify({ number: 7, state: "open", merged: false, base: { ref: "main" },
|
||||
head: { ref: "b", sha: "a".repeat(40) }, html_url: "h/7", ...(mergeable === undefined ? {} : { mergeable }) }));
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end("{}");
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
|
||||
GiteaClient.mergeRetryAfter = [1, 1, 1];
|
||||
return { client, server, merges: () => merges };
|
||||
}
|
||||
|
||||
const later = { status: 405, body: '{"message":"Please try again later"}' };
|
||||
const ok = { status: 200, body: "" };
|
||||
|
||||
test("a merge answered 'try again later' while the forge works out its mergeability, then merged", async (t) => {
|
||||
const cases: { name: string; answers: { status: number; body: string }[]; merged: boolean; asked: number }[] = [
|
||||
{ name: "merged on the third ask", answers: [later, later, ok], merged: true, asked: 3 },
|
||||
{ name: "still 'later' after the last wait: refused, with the forge's words", answers: [later], merged: false, asked: 4 },
|
||||
{ name: "a refusal of another kind is answered at once", answers: [{ status: 409, body: '{"message":"head moved"}' }], merged: false, asked: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
await t.test(c.name, async () => {
|
||||
const { client, server, merges } = await serve(c.answers);
|
||||
try {
|
||||
const done = client.mergePullRequest("novox", "hq", 7, "merge", false, "a".repeat(40));
|
||||
if (c.merged) await done;
|
||||
else await assert.rejects(done, (err: Error) => /40[59]/.test(err.message));
|
||||
assert.equal(merges(), c.asked);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("a pull request carries the forge's mergeable, and none when the forge did not say", async () => {
|
||||
for (const mergeable of [true, false, undefined]) {
|
||||
const { client, server } = await serve([ok], mergeable);
|
||||
try {
|
||||
assert.equal((await client.getPullRequest("novox", "hq", 7)).mergeable, mergeable);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -16,7 +16,7 @@ import { join } from "node:path";
|
||||
|
||||
import { MintedToken } from "../dist/token.js";
|
||||
import { GiteaClient } from "../dist/client.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
|
||||
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
|
||||
import { repoCheckFacts } from "../dist/pulls.js";
|
||||
|
||||
const ADMIN = "mesh-admin";
|
||||
@@ -26,6 +26,19 @@ interface Account { admin: boolean; password: string; active: boolean }
|
||||
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
|
||||
interface Team { id: number; name: string; units: any; members: Set<string> }
|
||||
|
||||
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
|
||||
const forgeRepos: ForgeRepo[] = [];
|
||||
const userRules = new Map<string, any>();
|
||||
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
|
||||
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
|
||||
const [owner, name] = full.split("/");
|
||||
const r = { owner, name, full, private: isPrivate, ownerVisibility, collaborators: new Map<string, string>() };
|
||||
forgeRepos.push(r);
|
||||
return r;
|
||||
}
|
||||
const repoJSON = (r: ForgeRepo) => ({ full_name: r.full, name: r.name, owner: { login: r.owner, visibility: r.ownerVisibility },
|
||||
private: r.private, archived: r.archived === true });
|
||||
|
||||
function fakeForge() {
|
||||
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
|
||||
const tokens: Token[] = [];
|
||||
@@ -61,11 +74,18 @@ function fakeForge() {
|
||||
return null;
|
||||
};
|
||||
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
|
||||
const permissionOf = (u: string | null, r: ForgeRepo): string => {
|
||||
if (isAdmin(u)) return "admin";
|
||||
if (u === r.owner) return "owner";
|
||||
if (r.owner === "novox" && teams.some((t) => t.members.has(u ?? "") && t.units.units_map?.["repo.code"] === "write")) return "write";
|
||||
if (u && r.collaborators.has(u)) return r.collaborators.get(u)!;
|
||||
return !r.private && r.ownerVisibility === "public" ? "read" : "none";
|
||||
};
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://fake");
|
||||
const p = url.pathname.replace(/^\/api\/v1/, "");
|
||||
const me = who(req);
|
||||
const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
|
||||
calls.push(`${req.method} ${p} as ${me}`);
|
||||
if (me === null) return json(res, 401, { message: "unauthorized" });
|
||||
|
||||
@@ -149,6 +169,23 @@ function fakeForge() {
|
||||
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
|
||||
return json(res, 404, { message: "branch does not exist" });
|
||||
}
|
||||
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
|
||||
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
const key = `${m[1]}/${m[2]}`;
|
||||
const repo = forgeRepos.find((r) => r.full === key);
|
||||
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
|
||||
// As gitea does: a whitelist login that cannot write the repository is dropped, and the write still answered.
|
||||
const keep = (b: any) => (Array.isArray(b.merge_whitelist_usernames)
|
||||
? { ...b, merge_whitelist_usernames: b.merge_whitelist_usernames.filter((u: string) => ["write", "admin", "owner"].includes(permissionOf(u, repo))) } : b);
|
||||
if (req.method === "PATCH" && m[3] === "main") {
|
||||
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
|
||||
Object.assign(userRules.get(key), keep(await read(req)));
|
||||
return json(res, 200, userRules.get(key));
|
||||
}
|
||||
if (req.method === "POST") { const b = keep(await read(req)); userRules.set(key, b); return json(res, 201, b); }
|
||||
}
|
||||
// gitea's reqAdmin on the whole group: reading the rules is administration too.
|
||||
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
|
||||
if (m) {
|
||||
@@ -176,7 +213,37 @@ function fakeForge() {
|
||||
if (!writes) return json(res, 403, { message: "write on code required" });
|
||||
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
|
||||
}
|
||||
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
|
||||
// Every repository of every owner, and who reads it (readall.ts): the site administrator reads all; the tools' account
|
||||
// a repository of novox through its team, a public one of a public owner, and one it is a collaborator of.
|
||||
if (req.method === "GET" && p === "/repos/search") {
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const seen = forgeRepos.filter((r) => permissionOf(me, r) !== "none");
|
||||
return json(res, 200, { data: page === 1 ? seen.map(repoJSON) : [] });
|
||||
}
|
||||
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
|
||||
if (m) {
|
||||
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
|
||||
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
|
||||
if (req.method === "GET" && !m[3]) {
|
||||
const mine = permissionOf(me, repo);
|
||||
return json(res, 200, { ...repoJSON(repo), permissions: { pull: true, push: ["write", "admin", "owner"].includes(mine), admin: ["admin", "owner"].includes(mine) } });
|
||||
}
|
||||
const user = decodeURIComponent(m[3] ?? "");
|
||||
if (req.method === "GET" && m[4]) {
|
||||
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
|
||||
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
|
||||
// As gitea answers: the collaborator's own permission, not what a team gives the account.
|
||||
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
|
||||
}
|
||||
if (req.method === "PUT" && !m[4]) {
|
||||
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
|
||||
if (!isAdmin(me) && me !== repo.owner) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
|
||||
if (repoFaults.refuse.has(repo.full)) return json(res, 422, { message: "the forge would not have it" });
|
||||
repo.collaborators.set(user, (await read(req))?.permission ?? "write");
|
||||
return json(res, 204, null);
|
||||
}
|
||||
}
|
||||
return json(res, 404, { message: "no such route in the fake" });
|
||||
});
|
||||
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules;
|
||||
@@ -402,3 +469,226 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio
|
||||
const writes = forge.calls.slice(from).filter((c: string) => /branch_protections/.test(c) && !/^GET /.test(c) && !/^(GET|HEAD) /.test(c));
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
// The mesh's accounts read every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and
|
||||
// jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen; and
|
||||
// once the tools' account read them, the build modules' fetch as nox-builder still could not.
|
||||
|
||||
test("the start's pass gives each of the mesh's accounts read, as a collaborator that reads only, on each repository it could not read, and on no other; an account the forge does not hold is said, not refused", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos(); // the account and its team exist, as after any mint
|
||||
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true }); // made by the forge-account provider
|
||||
forge.users.delete(AGENT_ACCOUNT); // no machine's key asked for it yet
|
||||
forgeRepos.splice(0);
|
||||
addRepo("novox/hq", true);
|
||||
addRepo("jschoubben/txt-game", false);
|
||||
addRepo("jschoubben/car-hunter", true);
|
||||
addRepo("jschoubben/snake", true);
|
||||
addRepo("shy/open", false, "limited");
|
||||
addRepo("elsewhere/secret", true);
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { ReadAll, readAllTools } = await import("../dist/readall.js");
|
||||
const lines: string[] = [];
|
||||
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), meshAccounts(), (l: string) => lines.push(l));
|
||||
assert.deepEqual(readAll.accounts, [TOOLS_ACCOUNT, BUILDER_ACCOUNT, AGENT_ACCOUNT]);
|
||||
const [unreadableTool, grantTool] = readAllTools(() => readAll);
|
||||
|
||||
// What the tool says before, per account: the four the tools' account cannot read, each with why; the builders'
|
||||
// account, in no write team here, also not novox/hq; nox-agent not on the forge.
|
||||
const before = await unreadableTool.run({}) as any;
|
||||
assert.equal(before.checked, 6);
|
||||
const of = (account: string) => before.unreadable.filter((u: any) => u.account === account).map((u: any) => u.repository).sort();
|
||||
assert.deepEqual(of(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
|
||||
assert.deepEqual(of(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
|
||||
assert.deepEqual(before.accounts, [{ account: TOOLS_ACCOUNT, unreadable: 4 }, { account: BUILDER_ACCOUNT, unreadable: 5 }]);
|
||||
assert.deepEqual(before.absent, [AGENT_ACCOUNT]);
|
||||
assert.match(before.unreadable.find((u: any) => u.account === BUILDER_ACCOUNT && u.repository === "jschoubben/car-hunter").why,
|
||||
/a private repository of jschoubben, on which nox-builder is no collaborator/);
|
||||
const builderOnly = await unreadableTool.run({ accounts: [BUILDER_ACCOUNT] }) as any;
|
||||
assert.deepEqual(builderOnly.accounts, [{ account: BUILDER_ACCOUNT, unreadable: 5 }]);
|
||||
await assert.rejects(unreadableTool.run({ accounts: ["someone"] }), /not one of the mesh's accounts/);
|
||||
assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why,
|
||||
/a private repository of jschoubben, on which mesh-tools is no collaborator/);
|
||||
assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/);
|
||||
assert.deepEqual((await fresh(env).listAllRepos()).map((r) => r.full_name).sort(), ["jschoubben/txt-game", "novox/hq"]);
|
||||
|
||||
// The account's own token cannot grant itself: the forge asks the repository's administration (the reason for the admin login).
|
||||
const own = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
|
||||
const self = await fetch(`${forge.url}/api/v1/repos/jschoubben/car-hunter/collaborators/${TOOLS_ACCOUNT}`,
|
||||
{ method: "PUT", headers: { Authorization: `token ${own}`, "Content-Type": "application/json" }, body: JSON.stringify({ permission: "read" }) });
|
||||
assert.equal(self.status, 404, "a repository the account cannot read is not even there for it");
|
||||
|
||||
const from = forge.calls.length;
|
||||
const r = await readAll.atStart();
|
||||
const grantedTo = (account: string) => r.granted.filter((x: any) => x.account === account).map((x: any) => x.repository).sort();
|
||||
assert.deepEqual(grantedTo(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
|
||||
assert.deepEqual(grantedTo(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
|
||||
assert.deepEqual(r.refused, []);
|
||||
assert.deepEqual(r.absent, [AGENT_ACCOUNT]);
|
||||
const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT "));
|
||||
assert.equal(puts.length, 9, "the repositories each read already were left alone, and the absent account was asked nothing");
|
||||
assert.ok(!puts.some((c) => c.includes(AGENT_ACCOUNT)));
|
||||
assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants");
|
||||
for (const repo of forgeRepos.filter((x) => x.collaborators.size)) {
|
||||
for (const [account, permission] of repo.collaborators) assert.equal(permission, "read", `${account} on ${repo.full} reads only`);
|
||||
}
|
||||
assert.equal(forgeRepos.find((x) => x.full === "jschoubben/car-hunter")!.collaborators.get(BUILDER_ACCOUNT), "read");
|
||||
assert.match(lines.join("\n"), /6 looked at; mesh-tools 4 granted read now .*; nox-builder 5 granted read now .*; not on the forge yet, so not granted: nox-agent/);
|
||||
|
||||
// The account made later is granted by hand (or at the next pass), its rows like any other.
|
||||
forge.users.set(AGENT_ACCOUNT, { admin: false, password: "unused", active: true });
|
||||
const agent = await grantTool.run({ accounts: [AGENT_ACCOUNT], repositories: ["jschoubben/car-hunter"] }) as any;
|
||||
assert.deepEqual(agent.granted, [{ account: AGENT_ACCOUNT, repository: "jschoubben/car-hunter" }]);
|
||||
assert.deepEqual(agent.absent, []);
|
||||
forge.users.delete(AGENT_ACCOUNT);
|
||||
|
||||
// After: the account's own token sees every repository, and the tool names none.
|
||||
assert.equal((await fresh(env).listAllRepos()).length, 6);
|
||||
assert.deepEqual(((await unreadableTool.run({})) as any).unreadable, []);
|
||||
// Granting again asks nothing of the forge but reads.
|
||||
const again = forge.calls.length;
|
||||
assert.deepEqual(((await grantTool.run({})) as any).granted, []);
|
||||
assert.ok(forge.calls.slice(again).every((c) => c.startsWith("GET ")));
|
||||
});
|
||||
|
||||
test("on the forge's events: a repository made is granted read at once, a known readable one asks nothing, a refusal is said, not thrown", async () => {
|
||||
const env = await environment();
|
||||
await fresh(env).listRepos();
|
||||
forgeRepos.splice(0);
|
||||
addRepo("jschoubben/txt-game", false);
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { ReadAll, readAllTools } = await import("../dist/readall.js");
|
||||
const lines: string[] = [];
|
||||
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true });
|
||||
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), [TOOLS_ACCOUNT, BUILDER_ACCOUNT], (l: string) => lines.push(l));
|
||||
await readAll.atStart();
|
||||
|
||||
const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [
|
||||
{ name: "a repository made", event: "forge.repository.created", repo: "jschoubben/new-private", make: true, asks: true, reads: "read" },
|
||||
{ name: "a fork made", event: "forge.fork", repo: "someone/fork", make: true, asks: true, reads: "read" },
|
||||
{ name: "a push to a repository seen readable", event: "forge.push", repo: "jschoubben/txt-game", asks: false, reads: "read" },
|
||||
{ name: "a push to a private repository never seen", event: "forge.push", repo: "jschoubben/quiet", make: true, asks: true, reads: "read" },
|
||||
{ name: "a repository the forge refuses the grant on", event: "forge.repository.created", repo: "jschoubben/stuck", make: true, refuse: true, asks: true, reads: "none" },
|
||||
{ name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: true, asks: true, reads: "none" },
|
||||
];
|
||||
for (const c of cases) {
|
||||
if (c.make) addRepo(c.repo, true);
|
||||
if (c.refuse) repoFaults.refuse.add(c.repo);
|
||||
if (c.unanswered) repoFaults.permission.add(c.repo);
|
||||
const from = forge.calls.length;
|
||||
const [owner, repo] = c.repo.split("/");
|
||||
await readAll.onEvent({ name: c.event, body: { owner, repo } });
|
||||
assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`);
|
||||
for (const account of [TOOLS_ACCOUNT, BUILDER_ACCOUNT]) {
|
||||
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(account) ?? (c.make ? "none" : "read"), c.reads, `${c.name}: ${account}`);
|
||||
}
|
||||
}
|
||||
assert.match(lines.join("\n"), /mesh-tools could not be given read on jschoubben\/stuck — .*422/);
|
||||
assert.match(lines.join("\n"), /nox-builder was given read on someone\/fork, on the forge's forge.fork/);
|
||||
const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any;
|
||||
assert.deepEqual(named.refused.map((x: any) => `${x.account} ${x.repository}`), [`${TOOLS_ACCOUNT} jschoubben/stuck`, `${BUILDER_ACCOUNT} jschoubben/stuck`]);
|
||||
await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/);
|
||||
assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/);
|
||||
// A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it.
|
||||
addRepo("jschoubben/after-mute", true);
|
||||
const pass = await readAll.grant();
|
||||
assert.ok(pass.granted.some((x: any) => x.repository === "jschoubben/after-mute" && x.account === BUILDER_ACCOUNT), "the pass did not stop at the repository it could not judge");
|
||||
assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why)));
|
||||
const listed = await readAllTools(() => readAll)[0].run({}) as any;
|
||||
assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown");
|
||||
repoFaults.refuse.clear();
|
||||
repoFaults.permission.clear();
|
||||
});
|
||||
|
||||
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
|
||||
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
|
||||
|
||||
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
|
||||
const env = await environment();
|
||||
const client = fresh(env);
|
||||
await client.listRepos(); // the account and its team exist, as after any mint
|
||||
forgeRepos.splice(0);
|
||||
addRepo("novox/hq", true);
|
||||
for (const full of ["jschoubben/car-hunter", "jschoubben/snake", "jschoubben/dotfiles"]) addRepo(full, true).collaborators.set(TOOLS_ACCOUNT, "read");
|
||||
const { GiteaAdmin } = await import("../dist/client.js");
|
||||
const { DeliveredWrites, writeTools, WRITABLE_EVENT } = await import("../dist/writes.js");
|
||||
const lines: string[] = [];
|
||||
const said: { type: string; body: any }[] = [];
|
||||
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
|
||||
async (type: string, body: unknown) => { said.push({ type, body }); });
|
||||
const [unwritableTool, grantTool] = writeTools(() => writes);
|
||||
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
|
||||
|
||||
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
|
||||
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
|
||||
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
|
||||
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
|
||||
const from = forge.calls.length;
|
||||
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
|
||||
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
|
||||
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
|
||||
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write", "a user's repository is written, not administered");
|
||||
assert.equal(userRules.has("jschoubben/car-hunter"), false, "a repository with no rule for main is given none");
|
||||
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
|
||||
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
|
||||
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
|
||||
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
|
||||
assert.match(lines.join("\n"), /broken names no repository/);
|
||||
|
||||
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
|
||||
// repository no module builds from is left read-only, named with why, and a grant naming it asks the forge nothing.
|
||||
repoFaults.refuse.add("jschoubben/snake");
|
||||
await writes.take({ key: "snake", op: "put", value: { v: 1, repository: "jschoubben/snake" } });
|
||||
assert.match(lines.join("\n"), /could not be given write on jschoubben\/snake, which builds snake — .*422/);
|
||||
const listed = await unwritableTool.run({}) as any;
|
||||
assert.deepEqual(listed.unwritable.map((u: any) => [u.repository, u.permission]), [["jschoubben/snake", "read"]]);
|
||||
assert.match(listed.unwritable[0].why, /builds snake.*gitea_grant_write/);
|
||||
const all = await grantTool.run({}) as any;
|
||||
assert.deepEqual(all.delivered, ["jschoubben/car-hunter", "jschoubben/snake", "novox/hq"]);
|
||||
assert.deepEqual(all.already, ["jschoubben/car-hunter", "novox/hq"]);
|
||||
assert.deepEqual(all.granted, []);
|
||||
assert.match(all.refused.find((x: any) => x.repository === "jschoubben/snake").why, /422.*would not have it/);
|
||||
assert.deepEqual(all.left_read_only.map((x: any) => x.repository), ["jschoubben/dotfiles"]);
|
||||
assert.match(all.left_read_only[0].why, /builds no registered module/);
|
||||
const puts = forge.calls.length;
|
||||
const named = await grantTool.run({ repositories: ["jschoubben/dotfiles"] }) as any;
|
||||
assert.deepEqual([named.granted, named.left_read_only.map((x: any) => x.repository)], [[], ["jschoubben/dotfiles"]]);
|
||||
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
|
||||
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
|
||||
repoFaults.refuse.clear();
|
||||
// The operator's rule is left as set (the operator, 2026-10-26: "Write, rule untouched"): only mesh-tools is added to its
|
||||
// merge whitelist; a status the mesh does not set and required approvals are said as blocking the mesh's merge, unchanged.
|
||||
const operators = { rule_name: "main", enable_push: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
|
||||
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "ci/external"], required_approvals: 2,
|
||||
block_admin_merge_override: false, require_signed_commits: true, block_on_outdated_branch: true, block_on_rejected_reviews: true };
|
||||
userRules.set("jschoubben/snake", structuredClone(operators));
|
||||
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
|
||||
assert.deepEqual(after.granted, ["jschoubben/snake"]);
|
||||
assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "write");
|
||||
assert.deepEqual(userRules.get("jschoubben/snake"), { ...operators, merge_whitelist_usernames: ["jschoubben", TOOLS_ACCOUNT] }, "only the whitelist changed");
|
||||
assert.deepEqual(after.merge_whitelist, [{ repository: "jschoubben/snake", changed: true, listed: ["jschoubben", TOOLS_ACCOUNT] }]);
|
||||
const blockedWhy = "writable, but main's rule blocks the mesh's merge: it requires the status ci/external, which the mesh does not set; " +
|
||||
"it requires 2 approving reviews on the forge, which the gate does not give; it requires signed commits, which the mesh does not make; " +
|
||||
"it refuses a branch behind main, which the mesh does not bring up to date; " +
|
||||
"it refuses a merge after a review asked for changes, which the gate does not clear";
|
||||
assert.deepEqual(after.merge_blocked, [{ repository: "jschoubben/snake", why: blockedWhy }]);
|
||||
const again = await unwritableTool.run({}) as any;
|
||||
assert.deepEqual([again.unwritable, again.merge_blocked], [[], [{ repository: "jschoubben/snake", why: blockedWhy }]]);
|
||||
const writesBefore = forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length;
|
||||
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_whitelist[0].changed, false);
|
||||
assert.equal(forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length, writesBefore, "a listed account is not written again");
|
||||
// The mesh's own statuses never block: a rule requiring only them, with mesh-tools listed, is not reported.
|
||||
userRules.set("jschoubben/snake", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT],
|
||||
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "mesh/repo-check"] });
|
||||
assert.deepEqual(((await unwritableTool.run({})) as any).merge_blocked, []);
|
||||
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_blocked, []);
|
||||
// The forge drops a whitelist login that cannot write and still answers 200: the whitelist is read back and that fails loud.
|
||||
userRules.set("jschoubben/dotfiles", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"] });
|
||||
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
|
||||
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
|
||||
userRules.delete("jschoubben/dotfiles");
|
||||
// A module gone from the indexer leaves its repository out of the delivered list.
|
||||
await writes.take({ key: "snake", op: "delete" });
|
||||
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ test("both statuses say the head and the trunk commit the verdict judged, first,
|
||||
const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } });
|
||||
assert.equal(old.description, "pass: every machine composes");
|
||||
assert.equal(trunkNamed(old.description), undefined);
|
||||
// ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk.
|
||||
assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`),
|
||||
MOVED.slice(0, 12));
|
||||
});
|
||||
|
||||
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
import { keepMainRules } from "./mainrules.js";
|
||||
|
||||
export const TOOLS_ACCOUNT = "mesh-tools";
|
||||
/** The build modules' account, whose keys fetch over ssh (internal/forgeaccount BuilderUser, ADR 0466 §3). */
|
||||
export const BUILDER_ACCOUNT = "nox-builder";
|
||||
/** The machines' agents' account, whose keys ssh-client reports (internal/forgeaccount AgentUser, ADR 0410 §1). */
|
||||
export const AGENT_ACCOUNT = "nox-agent";
|
||||
/** Every forge account the mesh's own parts use: each reads every repository on the forge (readall.ts; the operator,
|
||||
* 2026-10-26). internal/forgeaccount's test holds the two Go names to its own constants. */
|
||||
export function meshAccounts(tools: string = TOOLS_ACCOUNT): string[] {
|
||||
return [tools, BUILDER_ACCOUNT, AGENT_ACCOUNT];
|
||||
}
|
||||
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
|
||||
export const TOOLS_ORG = "novox";
|
||||
export const TOOLS_TEAM = "mesh-tools";
|
||||
|
||||
@@ -492,7 +492,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_statuses",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
|
||||
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
@@ -508,11 +508,12 @@ 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; mergeable?: boolean } | 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,
|
||||
...(p.mergeable === undefined ? {} : { mergeable: p.mergeable }) };
|
||||
if (!ref) ref = p.head_sha ?? "";
|
||||
if (!base) base = p.base ?? "";
|
||||
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
|
||||
@@ -527,8 +528,18 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
} catch (err) {
|
||||
unreadable = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
// Whether this account may write the repository at all (issue 1945): a commit status and a merge need it, and a
|
||||
// repository of a user it only reads refuses both 403. Not said when the forge would not answer.
|
||||
let writes: boolean | undefined;
|
||||
try {
|
||||
writes = (await gitea.getRepo(owner, repo)).writes;
|
||||
} catch {
|
||||
writes = undefined;
|
||||
}
|
||||
const merge = mergeableUnder(base, rule, statuses, unreadable);
|
||||
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
|
||||
merge: mergeableUnder(base, rule, statuses, unreadable) };
|
||||
merge: writes === undefined ? merge : { ...merge, writes,
|
||||
...(writes ? {} : { cannot_write: `the forge account cannot write ${owner}/${repo}: gitea_grant_write` }) } };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// The account that sets commit statuses and merges writes every repository that builds a registered module, and no other
|
||||
// (the operator, 2026-10-26: "yes the mesh should get write access", on the repositories the mesh delivers; novox/hq issue
|
||||
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
|
||||
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
|
||||
//
|
||||
// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge
|
||||
// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines'
|
||||
// `nox-agent` only fetch, and keep read (readall.ts).
|
||||
//
|
||||
// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its
|
||||
// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads
|
||||
// that state (its manifest's `reads`) with a watch: the watch hands the latest value of every key when it starts, and then
|
||||
// every change, so a repository is granted at the module's start and when a module of it is first registered. Nothing
|
||||
// polls. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
|
||||
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
|
||||
//
|
||||
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
|
||||
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
|
||||
// whose merge the forge would refuse, reads that repository again on it.
|
||||
//
|
||||
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
|
||||
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
|
||||
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
|
||||
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
|
||||
// blocked, with why (merge_blocked).
|
||||
//
|
||||
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
|
||||
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
|
||||
|
||||
import type { ForgeRepository } from "./client.js";
|
||||
import type { BranchProtection } from "./protection.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
|
||||
|
||||
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
|
||||
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
|
||||
/** The event said once an account writes a repository it could not write before. */
|
||||
export const WRITABLE_EVENT = "repo.writable";
|
||||
|
||||
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
|
||||
export interface WriteForge {
|
||||
everyRepository(): Promise<ForgeRepository[]>;
|
||||
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
|
||||
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
|
||||
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
|
||||
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
|
||||
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
|
||||
}
|
||||
|
||||
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
|
||||
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
|
||||
|
||||
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
|
||||
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
|
||||
if (!rule) return [];
|
||||
const blocks: string[] = [];
|
||||
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
|
||||
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
|
||||
}
|
||||
if (rule.enable_status_check === true) {
|
||||
for (const c of rule.status_check_contexts ?? []) {
|
||||
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
|
||||
}
|
||||
}
|
||||
const approvals = Number(rule.required_approvals ?? 0);
|
||||
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
|
||||
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
|
||||
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
|
||||
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
|
||||
|
||||
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
|
||||
export interface ModulesChange {
|
||||
key: string;
|
||||
op: "put" | "delete";
|
||||
value?: unknown;
|
||||
revision?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface Written {
|
||||
account: string;
|
||||
/** The repositories that build a registered module, as the indexer says now. */
|
||||
delivered: string[];
|
||||
/** Made writable now, each read back from the forge. */
|
||||
granted: string[];
|
||||
/** Written already (a collaborator of a user's repository), or written through the organisation's team. */
|
||||
already: string[];
|
||||
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
|
||||
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
|
||||
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
|
||||
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
|
||||
merge_blocked: { repository: string; why: string }[];
|
||||
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
|
||||
refused: { repository: string; why: string }[];
|
||||
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
|
||||
left_read_only: { repository: string; permission: string; why: string }[];
|
||||
}
|
||||
|
||||
const WRITES = new Set(["write", "admin", "owner"]);
|
||||
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
function split(full: string): [string, string] {
|
||||
const [owner, name, ...rest] = String(full ?? "").split("/");
|
||||
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
|
||||
return [owner, name];
|
||||
}
|
||||
|
||||
export class DeliveredWrites {
|
||||
/** Module to the repository it builds from, as the indexer's state says. */
|
||||
readonly modules = new Map<string, string>();
|
||||
/** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */
|
||||
heard = false;
|
||||
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
|
||||
readonly writable = new Set<string>();
|
||||
|
||||
constructor(private readonly forge: WriteForge, readonly account: string, readonly org: string,
|
||||
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`),
|
||||
private readonly emit: (type: string, body: unknown) => Promise<void> = async () => {}) {}
|
||||
|
||||
/** The repositories that build a registered module, sorted. */
|
||||
delivered(): string[] {
|
||||
return [...new Set(this.modules.values())].sort();
|
||||
}
|
||||
|
||||
/** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */
|
||||
async take(change: ModulesChange): Promise<void> {
|
||||
this.heard = true;
|
||||
if (change.op === "delete") {
|
||||
this.modules.delete(change.key);
|
||||
return;
|
||||
}
|
||||
const repository = String((change.value as { repository?: unknown } | undefined)?.repository ?? "").trim();
|
||||
try {
|
||||
split(repository);
|
||||
} catch {
|
||||
this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
|
||||
return;
|
||||
}
|
||||
this.modules.set(change.key, repository);
|
||||
if (this.writable.has(repository)) return;
|
||||
try {
|
||||
const r = await this.grant([repository]);
|
||||
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
|
||||
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository}, which builds ${change.key} — ${x.why}`);
|
||||
} catch (err) {
|
||||
this.log(`whether ${this.account} writes ${repository} (module ${change.key}) could not be settled — ${said(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant write on every delivered repository the account cannot write (or on those named, each of which must be
|
||||
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
|
||||
async grant(only?: string[]): Promise<Written> {
|
||||
if (!this.heard) {
|
||||
throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` +
|
||||
"(the watch is not granted or not answered); nothing is granted");
|
||||
}
|
||||
const delivered = this.delivered();
|
||||
const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
|
||||
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
|
||||
const targets: string[] = [];
|
||||
for (const full of named ?? delivered) {
|
||||
split(full);
|
||||
if (delivered.includes(full)) targets.push(full);
|
||||
else result.left_read_only.push({ repository: full, permission: "unchanged",
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
}
|
||||
for (const full of targets) {
|
||||
const [owner, name] = split(full);
|
||||
let permission: string;
|
||||
try {
|
||||
if (!(await this.forge.repository(owner, name))) {
|
||||
result.refused.push({ repository: full, why: "the forge holds no repository by that name" });
|
||||
continue;
|
||||
}
|
||||
permission = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (owner === this.org) {
|
||||
if (WRITES.has(permission)) {
|
||||
this.writable.add(full);
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
|
||||
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
|
||||
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
|
||||
"set again at the next mint of the tools' token)" });
|
||||
continue;
|
||||
}
|
||||
// A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
|
||||
let changed = false;
|
||||
if (!WRITES.has(permission)) {
|
||||
try {
|
||||
await this.forge.addWriteCollaborator(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: said(err) });
|
||||
continue;
|
||||
}
|
||||
let after: string;
|
||||
try {
|
||||
after = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (!WRITES.has(after)) {
|
||||
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
try {
|
||||
const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
|
||||
result.merge_whitelist.push({ repository: full, changed: listedNow,
|
||||
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
|
||||
const blocks = mergeBlocks(rule, this.account);
|
||||
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
|
||||
} catch (err) {
|
||||
result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
this.writable.add(full);
|
||||
if (!changed) {
|
||||
result.already.push(full);
|
||||
continue;
|
||||
}
|
||||
result.granted.push(full);
|
||||
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
|
||||
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
|
||||
}
|
||||
if (!named) {
|
||||
for (const repo of await this.forge.everyRepository()) {
|
||||
if (repo.owner === this.org || delivered.includes(repo.full_name)) continue;
|
||||
let permission: string;
|
||||
try {
|
||||
permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
|
||||
} catch (err) {
|
||||
permission = `unknown — ${said(err)}`;
|
||||
}
|
||||
if (WRITES.has(permission)) continue;
|
||||
result.left_read_only.push({ repository: repo.full_name, permission,
|
||||
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Every delivered repository the account cannot write, as the forge says now, and why. */
|
||||
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
|
||||
merge_blocked: { repository: string; why: string }[] }> {
|
||||
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
|
||||
const delivered = this.delivered();
|
||||
const out: { repository: string; permission: string; why: string }[] = [];
|
||||
const blocked: { repository: string; why: string }[] = [];
|
||||
for (const full of delivered) {
|
||||
const [owner, name] = split(full);
|
||||
let permission: string;
|
||||
try {
|
||||
permission = await this.forge.repositoryPermission(owner, name, this.account);
|
||||
} catch (err) {
|
||||
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (WRITES.has(permission)) {
|
||||
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
|
||||
try {
|
||||
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
|
||||
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
|
||||
} catch (err) {
|
||||
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
|
||||
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
|
||||
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
|
||||
}
|
||||
return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
|
||||
}
|
||||
}
|
||||
|
||||
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
|
||||
|
||||
/** The two tools: which delivered repositories the merging account cannot write, and the grant now. */
|
||||
export function writeTools(writes: () => DeliveredWrites): Tool[] {
|
||||
return [
|
||||
{
|
||||
name: "gitea_unwritable_repos",
|
||||
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
|
||||
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
|
||||
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
|
||||
"delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
|
||||
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
|
||||
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
|
||||
input: {},
|
||||
run: async () => writes().unwritable(),
|
||||
},
|
||||
{
|
||||
name: "gitea_grant_write",
|
||||
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
|
||||
"user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
|
||||
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
|
||||
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
|
||||
"is changed by nobody and said in merge_blocked. " +
|
||||
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
|
||||
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
|
||||
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
|
||||
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
|
||||
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
|
||||
input: {
|
||||
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
|
||||
},
|
||||
run: async (args) => writes().grant(Array.isArray(args.repositories) ? args.repositories.map(String) : undefined),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -43,6 +43,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
|
||||
return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil
|
||||
},
|
||||
Kept: m.KeptLookup,
|
||||
Has: m.Has,
|
||||
Release: func(ws string) error { return m.spaces().Release(ws) },
|
||||
Logf: m.Say,
|
||||
}
|
||||
@@ -80,19 +81,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, Has: m.Has,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
@@ -111,3 +116,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
|
||||
return buildstep.SeatTools(Seat, "Go", p)
|
||||
}
|
||||
|
||||
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
|
||||
const StepEndTool = "build_go_step_end"
|
||||
|
||||
// stepScan is the module's steps state as buildstep reads it.
|
||||
func (m *Module) stepScan() buildstep.Scan {
|
||||
return buildstep.ScanOf(stdio.State(delivery.StepsState))
|
||||
}
|
||||
|
||||
@@ -183,12 +183,12 @@ func (m *Module) buildEnv(dir string) []string {
|
||||
} else if modfile, ok := m.pinModfile(dir); ok {
|
||||
mod += " -modfile=" + modfile // a pin on a pull request's head the fetch placed beside the workspace (pins.go)
|
||||
}
|
||||
return []string{
|
||||
return append([]string{
|
||||
"CGO_ENABLED=0",
|
||||
"GOFLAGS=" + recipeFlags + " " + mod,
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
|
||||
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
|
||||
"GOPROXY=off",
|
||||
}
|
||||
}, m.threadEnv()...)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace.
|
||||
@@ -108,7 +110,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err
|
||||
r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD")
|
||||
commit := strings.TrimSpace(r.Stdout)
|
||||
if r.Status != 0 || r.Err != "" || commit == "" {
|
||||
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", firstLine(r.Err+"\n"+r.Stderr))
|
||||
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
|
||||
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
|
||||
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
|
||||
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
|
||||
// no slice to size a step to, and no thread limit is set.
|
||||
|
||||
// cpuLimits is what one step is held to now.
|
||||
func (m *Module) cpuLimits() buildcpu.Limits {
|
||||
slots := 1
|
||||
switch {
|
||||
case m.Capacity != nil:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
// threadEnv is the environment (nothing without a share) every go command of a step is given for its threads: GOMAXPROCS sets its -p, the compiler's
|
||||
// concurrency, a test binary's processors and -parallel.
|
||||
func (m *Module) threadEnv() []string {
|
||||
return buildcpu.Env(m.cpuLimits().Threads)
|
||||
}
|
||||
|
||||
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
|
||||
// to (its unit and its slice), read from its cgroup rather than assumed.
|
||||
func (m *Module) cpuStatus() map[string]any {
|
||||
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
@@ -19,6 +20,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
|
||||
@@ -39,6 +42,33 @@ const besideVariable = "MESH_CHECK_BESIDE"
|
||||
// test log) with each verdict.
|
||||
const ReadsOutsideMarker = "reads-outside"
|
||||
|
||||
// NeedsMarker is the name of a file in a package's directory that names, one per line, what its integration tests need
|
||||
// of the node they run on (issue 890; mesh-sdk delivery.AskNeeds): `node-build-node` for tests that read the Node build
|
||||
// module's installed toolchain. The digest reports it (DigestResult.Needs); a check ask leaves such a package untested on
|
||||
// a node that lacks it, and the checker asks it again of a node that has it. A marker beside the tests, as
|
||||
// ReadsOutsideMarker is, and not a manifest field: it is a property of the package's tests, read only where they are
|
||||
// run, and the manifest describes the module on the machines. The unit level is never given anything of the node (ADR
|
||||
// 0533 §1), so a marker binds the integration level alone. Blank lines and lines starting with # are not read.
|
||||
const NeedsMarker = "check-needs"
|
||||
|
||||
// readNeeds is what the marker in dir names; nil when there is none.
|
||||
func readNeeds(dir string) ([]string, error) {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, NeedsMarker))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var needs []string
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||||
needs = append(needs, l)
|
||||
}
|
||||
}
|
||||
return needs, nil
|
||||
}
|
||||
|
||||
// integrationTags are the build tags of the integration level, its run and its digest alike: integration, and isolation
|
||||
// for the bubblewrap proofs (mesh-sdk's and the build modules'), which a build node can run.
|
||||
var integrationTags = []string{"integration", "isolation"}
|
||||
@@ -101,6 +131,8 @@ type DigestResult struct {
|
||||
// ReadsOutside names the packages that carry the marker: each has a digest that no kept verdict can have (a fresh
|
||||
// random one), so the check runs it every time.
|
||||
ReadsOutside []string `json:"reads_outside,omitempty"`
|
||||
// Needs are the packages whose tests at this level need something of the node, as their NeedsMarker names it.
|
||||
Needs []delivery.PackageNeeds `json:"needs,omitempty"`
|
||||
// JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
|
||||
// runner workspace.
|
||||
JudgedBy string `json:"judged_by,omitempty"`
|
||||
@@ -206,12 +238,13 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
|
||||
if err != nil {
|
||||
return DigestResult{}, err
|
||||
}
|
||||
if err := digestModule(&res, pkgs, tree, header, besideHeader); err != nil {
|
||||
if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
|
||||
return DigestResult{}, err
|
||||
}
|
||||
}
|
||||
sort.Strings(res.WithoutTests)
|
||||
sort.Strings(res.ReadsOutside)
|
||||
sort.Slice(res.Needs, func(i, j int) bool { return res.Needs[i].Package < res.Needs[j].Package })
|
||||
for _, p := range res.ReadsOutside {
|
||||
if m.Say != nil {
|
||||
m.Say("the %s level of %s always runs: its directory carries %s, so its tests read files outside it that no digest names", level, p, ReadsOutsideMarker)
|
||||
@@ -256,7 +289,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
|
||||
|
||||
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
|
||||
// module that is listed, not only a dependency, and not a variant of another.
|
||||
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string) error {
|
||||
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error {
|
||||
roots := []string{}
|
||||
for path, p := range pkgs {
|
||||
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
|
||||
@@ -274,6 +307,15 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
|
||||
if testMain == nil {
|
||||
return fmt.Errorf("go list gave no test binary for %s", path)
|
||||
}
|
||||
if needs {
|
||||
n, err := readNeeds(root.Dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: its %s: %w", path, NeedsMarker, err)
|
||||
}
|
||||
if len(n) > 0 {
|
||||
res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n})
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root.Dir, ReadsOutsideMarker)); err == nil {
|
||||
// The digest of a package that reads outside itself is one nothing can have kept: the key is a fresh random
|
||||
// hex, so the ask finds no kept verdict and runs it, and what the checker writes under it is never read again.
|
||||
|
||||
@@ -213,6 +213,16 @@ func TestAPackageWithTheReadsOutsideMarkerIsNeverKept(t *testing.T) {
|
||||
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] {
|
||||
t.Errorf("a has no marker and its digest moved: %s, %s, before %s", first.Packages[a], second.Packages[a], plain.Packages[a])
|
||||
}
|
||||
|
||||
// Issue 890/AC3: a package whose directory carries the needs marker is named with its needs at the integration level,
|
||||
// comments and blank lines not read, and at the unit level not at all.
|
||||
edit(t, tree, "a/"+NeedsMarker, "# its integration tests copy the installed Node toolchain\n\nnode-build-node\n")
|
||||
if n := digests(t, m, LevelIntegration).Needs; len(n) != 1 || n[0].Package != a || strings.Join(n[0].Needs, ",") != "node-build-node" {
|
||||
t.Errorf("the integration level's needs = %+v, want %s needing node-build-node", n, a)
|
||||
}
|
||||
if n := digests(t, m, LevelUnit).Needs; len(n) != 0 {
|
||||
t.Errorf("the unit level names needs: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1935: a catalogue module's packages read ../../module.json and the module's files: a change to them changes the
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
@@ -255,7 +256,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
|
||||
func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
|
||||
r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
dirs := []string{}
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
@@ -292,7 +293,7 @@ func (m *Module) goEnv() []string {
|
||||
if m.GoModCache != "" {
|
||||
modcache = m.GoModCache
|
||||
}
|
||||
return []string{
|
||||
return append([]string{
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOFLAGS=-mod=mod",
|
||||
"GOMODCACHE=" + modcache,
|
||||
@@ -300,7 +301,7 @@ func (m *Module) goEnv() []string {
|
||||
"GOPRIVATE=git.novox.be",
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_SSH_COMMAND=ssh -o BatchMode=yes",
|
||||
}
|
||||
}, m.threadEnv()...)
|
||||
}
|
||||
|
||||
// goEnvFor is goEnv for a command run in dir: a directory that vendors (vendor/modules.txt) is read from its vendor
|
||||
@@ -331,10 +332,11 @@ func (m *Module) clock() time.Time {
|
||||
func (m *Module) now() time.Time { return m.clock() }
|
||||
|
||||
// causeLine is the first line of a go command's output that says something other than Go's own progress: `go:
|
||||
// downloading ...` comes first when Go fetches a module, and the cause of a failure on a later line.
|
||||
// downloading ...` comes first when Go fetches a module, and `# <package>` heads a compile error of go build or go vet
|
||||
// (issue 1703: the reason said only the package), and the cause of a failure on a later line.
|
||||
func causeLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") && !strings.HasPrefix(l, "# ") {
|
||||
return l
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,3 +302,16 @@ func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issue 1703: a go command's reason is its cause, not Go's progress nor go build's `# <package>` heading.
|
||||
func TestCauseLineSkipsProgressAndThePackageHeading(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"go: downloading x v1\n# git.example/m/cmd\n./main.go:3:2: undefined: y\n": "./main.go:3:2: undefined: y",
|
||||
"\ngo: example.com/x@v1: reading: 404\n": "go: example.com/x@v1: reading: 404",
|
||||
"go: downloading x v1\n": "go: downloading x v1",
|
||||
} {
|
||||
if got := causeLine(in); got != want {
|
||||
t.Errorf("causeLine(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,13 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
@@ -28,6 +31,7 @@ func toolchainValues(env []string) []string {
|
||||
// 1572/AC1 (ADR 0477 §1): every environment a step gives its go commands says GOTOOLCHAIN=local, once, and names no
|
||||
// release.
|
||||
func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
|
||||
m := &Module{Cache: t.TempDir()}
|
||||
vend := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(vend, "vendor"), 0o755)
|
||||
@@ -41,6 +45,24 @@ func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
|
||||
if got := toolchainValues(env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" {
|
||||
t.Errorf("%s: GOTOOLCHAIN settings %v, want exactly GOTOOLCHAIN=local", name, got)
|
||||
}
|
||||
// Issue 1806: and its threads, its part of the node's processor cap (probeEnv runs no build).
|
||||
if procs := strings.Count(strings.Join(env, "\n"), "GOMAXPROCS="); name != "probeEnv" && procs != 1 {
|
||||
t.Errorf("%s: GOMAXPROCS said %d times, want once: %v", name, procs, env)
|
||||
}
|
||||
}
|
||||
// Without the composer's share no thread limit; with it, the share over its own three slots (ADR 0553 §4).
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "")
|
||||
m = &Module{Cache: t.TempDir(), Node: "shanks", Capacity: func() int { return 3 }}
|
||||
if l := m.cpuLimits(); l.Threads != 0 || strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS=") {
|
||||
t.Errorf("without the share: %+v, goEnv %v", l, m.goEnv())
|
||||
}
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
|
||||
want := buildcpu.Threads(50, runtime.NumCPU(), 3)
|
||||
if l := m.cpuLimits(); l.Slots != 3 || !strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS="+strconv.Itoa(want)) {
|
||||
t.Errorf("limits %+v, goEnv %v; want %d threads", l, m.goEnv(), want)
|
||||
}
|
||||
if st := m.cpuStatus(); st["limits"].(buildcpu.Limits) != m.cpuLimits() {
|
||||
t.Errorf("the status says %v", st)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
|
||||
Workspace: filepath.Join(m.Work, workspace),
|
||||
Caches: m.caches(),
|
||||
WriteCaches: write,
|
||||
Env: []string{"PATH=" + path, "LC_ALL=C"},
|
||||
Env: append([]string{"PATH=" + path, "LC_ALL=C"}, m.threadEnv()...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// levelArg is the first argument that makes this binary a step's shut-off process: the integration level's, and, when
|
||||
@@ -140,6 +142,49 @@ func (m *Module) nodeToolchains() []string {
|
||||
return []string{dir}
|
||||
}
|
||||
|
||||
// nodeToolchainMark is the file the Node build module writes in a toolchain directory once it is installed whole
|
||||
// (mesh-build-node toolchain.go installedMark).
|
||||
const nodeToolchainMark = ".installed"
|
||||
|
||||
// Has says whether this node has a need a check ask or a package's tests name (issue 890, ADR 0552; mesh-sdk
|
||||
// delivery.AskNeeds), read from the node itself at every ask: node-build-node when the Node build module's toolchains
|
||||
// directory, given to the integration level as nodeToolchains gives it, holds a toolchain installed whole (its mark and
|
||||
// its package-lock.json readable by this account), and node, npm and bwrap are installed: what mesh-build-node's
|
||||
// integration tests (realMachine) look for before they run. Any other need it lacks.
|
||||
func (m *Module) Has(need string) bool {
|
||||
if m.HasNeed != nil {
|
||||
return m.HasNeed(need)
|
||||
}
|
||||
switch need {
|
||||
case delivery.NeedNodeBuildNode:
|
||||
dirs := m.nodeToolchains()
|
||||
if len(dirs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, p := range []string{"node", "npm", "bwrap"} {
|
||||
if _, err := exec.LookPath(p); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
entries, err := os.ReadDir(dirs[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
dir := filepath.Join(dirs[0], e.Name())
|
||||
if _, err := os.Stat(filepath.Join(dir, nodeToolchainMark)); err != nil {
|
||||
continue
|
||||
}
|
||||
if f, err := os.Open(filepath.Join(dir, "package-lock.json")); err == nil {
|
||||
f.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -40,6 +41,8 @@ type PackageResult struct {
|
||||
Tests int `json:"tests"`
|
||||
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
// Skipped is how many of the package's tests skipped: a pass that skipped proved less than it says, so the checker keeps no verdict for it.
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// TestTime is one test and the time it took.
|
||||
@@ -52,6 +55,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 +133,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 +192,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,26 +220,37 @@ 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++
|
||||
if ev.Action == "skip" {
|
||||
r.res.Skipped++
|
||||
p.Skipped++
|
||||
}
|
||||
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 +261,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 +278,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 +452,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
|
||||
|
||||
@@ -8,9 +8,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -40,6 +42,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,
|
||||
@@ -68,7 +72,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
steps := m.StepTools()
|
||||
booted := time.Now()
|
||||
puller := m.Puller(steps, nil)
|
||||
m.Held = puller
|
||||
if m.Node != "" {
|
||||
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
|
||||
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
|
||||
}
|
||||
if m.Node != "" {
|
||||
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
|
||||
go func() { _ = puller.Pull(ctx) }()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process.
|
||||
@@ -67,7 +68,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error {
|
||||
return nil // no mirror was used
|
||||
}
|
||||
if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" {
|
||||
return fmt.Errorf("git repack in %s: %s", dir, firstLine(r.Err+" "+r.Stderr))
|
||||
return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
return os.Remove(alternates)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -121,20 +122,35 @@ func TestTwoUpdatesOfOneMirrorNeverRunAtOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it.
|
||||
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it, and the line saying so carries
|
||||
// git's own words. The forge's refusal of a repository its account cannot read came out as "git -C: error:" (ace,
|
||||
// 2026-10-26: git writes a bare "error:" line before the forge's message).
|
||||
func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) {
|
||||
f := &fakeStep{gomods: "go.mod\n"}
|
||||
f.rule = func(line string) (r runAnswer, ok bool) {
|
||||
if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") {
|
||||
return runAnswer{Status: 1, Stderr: "fatal: no space left\n"}, true
|
||||
return runAnswer{Status: 128, Stderr: "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n"}, true
|
||||
}
|
||||
return r, false
|
||||
}
|
||||
m, states := fetchModule(t, f)
|
||||
var mu sync.Mutex
|
||||
var said []string
|
||||
m.Say = func(format string, a ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
said = append(said, fmt.Sprintf(format, a...))
|
||||
}
|
||||
ws := fetchOnce(t, m, states, fetchArgs).Workspace
|
||||
if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil {
|
||||
t.Errorf("the workspace borrows from a mirror that is not there")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
all := strings.Join(said, "\n")
|
||||
if !strings.Contains(all, "is not used: git fetch: error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.") {
|
||||
t.Errorf("the mirror's line lost git's words:\n%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
func sleepABit() { time.Sleep(30 * time.Millisecond) }
|
||||
|
||||
@@ -28,7 +28,12 @@ type Module struct {
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
|
||||
Held *buildstep.Puller
|
||||
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
|
||||
@@ -70,6 +75,8 @@ type Module struct {
|
||||
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
|
||||
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
|
||||
NodeToolchains string
|
||||
// HasNeed answers Has in place of the node's own reading; a test's.
|
||||
HasNeed func(need string) bool
|
||||
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
|
||||
// program, StartBus in bus.go, when nil).
|
||||
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
|
||||
@@ -118,6 +125,7 @@ func (m *Module) Start(ctx context.Context) {
|
||||
m.Say("the toolchain: %v", err)
|
||||
}
|
||||
m.CleanWorkspaces(time.Now())
|
||||
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -181,13 +189,14 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
|
||||
}
|
||||
|
||||
// Status is what the module reports of itself: the node, the recipe, the toolchain found and why it refuses, if it
|
||||
// does.
|
||||
// does, and the processors its steps may take (issue 1806).
|
||||
func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
err := m.Check(ctx)
|
||||
installed, _ := m.Probe(ctx)
|
||||
out := map[string]any{
|
||||
"node": m.Node, "recipe": m.Recipe.Version(), "installed": installed,
|
||||
"postgres-major": m.Recipe.PostgresMajor, "nats-server": m.Recipe.NatsServer, "refusing": err != nil,
|
||||
"cpu": m.cpuStatus(),
|
||||
}
|
||||
if err != nil {
|
||||
out["why"] = err.Error()
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
{Name: "short_probe", Run: run},
|
||||
}, m.StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -71,6 +71,47 @@ func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T
|
||||
if slices.Contains(s.roBinds, missing) {
|
||||
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
|
||||
}
|
||||
|
||||
// Issue 890/AC4: such a node lacks node-build-node; one with an installed toolchain and node and npm has it; nothing
|
||||
// else is had.
|
||||
bin := t.TempDir()
|
||||
for _, p := range []string{"node", "npm", "bwrap"} {
|
||||
if err := os.WriteFile(filepath.Join(bin, p), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
m := &Module{NodeToolchains: missing}
|
||||
if m.Has(delivery.NeedNodeBuildNode) {
|
||||
t.Error("a node with no Node toolchain has node-build-node")
|
||||
}
|
||||
installed := t.TempDir()
|
||||
m.NodeToolchains = installed
|
||||
if m.Has(delivery.NeedNodeBuildNode) {
|
||||
t.Error("a node with an empty toolchains directory has node-build-node")
|
||||
}
|
||||
half := filepath.Join(installed, "0.1.0")
|
||||
if err := os.Mkdir(half, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(half, "package-lock.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Has(delivery.NeedNodeBuildNode) {
|
||||
t.Error("a toolchain without its installed mark counts")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(half, nodeToolchainMark), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Has(delivery.NeedNodeBuildNode) || m.Has("gpu") {
|
||||
t.Errorf("with a toolchain installed: node-build-node %v, gpu %v; want true, false", m.Has(delivery.NeedNodeBuildNode), m.Has("gpu"))
|
||||
}
|
||||
if err := os.Remove(filepath.Join(bin, "bwrap")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.Has(delivery.NeedNodeBuildNode) {
|
||||
t.Error("a node with no bubblewrap installed has node-build-node")
|
||||
}
|
||||
}
|
||||
|
||||
// 1891 (ADR 0533 §1): the unit level is not given them.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// A package counts its own skipped tests, so the checker withholds a kept verdict from that package alone (issue 890).
|
||||
func TestEachPackageCountsItsOwnSkippedTests(t *testing.T) {
|
||||
log := `{"Action":"run","Package":"m/a","Test":"TestA"}
|
||||
{"Action":"skip","Package":"m/a","Test":"TestA","Elapsed":0}
|
||||
{"Action":"run","Package":"m/b","Test":"TestB"}
|
||||
{"Action":"pass","Package":"m/b","Test":"TestB","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/a","Elapsed":0}
|
||||
{"Action":"pass","Package":"m/b","Elapsed":0}
|
||||
`
|
||||
res := &TestResult{}
|
||||
rd := newReader(res, ".")
|
||||
rd.read(log)
|
||||
rd.finish()
|
||||
got := map[string]int{}
|
||||
for _, p := range res.Packages {
|
||||
got[p.Package] = p.Skipped
|
||||
}
|
||||
if res.Skipped != 1 || got["m/a"] != 1 || got["m/b"] != 0 {
|
||||
t.Errorf("skipped = %d, per package %v, want 1 with m/a 1 and m/b 0", res.Skipped, got)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
const forgePrefix = "git.novox.be/novox/"
|
||||
@@ -125,7 +127,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin
|
||||
repository := "novox/" + p.repo
|
||||
r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, firstLine(r.Err+" "+r.Stderr))
|
||||
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
commit, ref := "", ""
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
|
||||
@@ -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)
|
||||
@@ -78,29 +128,44 @@ type StepLine struct {
|
||||
Started string `json:"started"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
What string `json:"what,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
|
||||
}
|
||||
@@ -116,13 +181,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out := make([]StepLine, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
|
||||
if !s.Ended.IsZero() {
|
||||
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
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 +195,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 +242,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"
|
||||
|
||||
@@ -94,7 +96,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
|
||||
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"})
|
||||
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
|
||||
m := readModule(steps, fakeState{})
|
||||
out, err := tool(t, m, StepsReadTool)(map[string]any{})
|
||||
@@ -109,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
if !slices.Equal(ids, []string{"c", "b", "a"}) {
|
||||
t.Errorf("ids = %v, want c b a", ids)
|
||||
}
|
||||
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
|
||||
if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed {
|
||||
t.Errorf("first = %+v", lines[0])
|
||||
}
|
||||
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,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}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -40,6 +41,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
|
||||
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.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/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": [
|
||||
{
|
||||
@@ -39,6 +39,7 @@
|
||||
"build_go_release",
|
||||
"build_go_step",
|
||||
"build_go_steps",
|
||||
"build_go_step_end",
|
||||
"build_go_toolchains",
|
||||
"build_go_status",
|
||||
"build_go_health"
|
||||
|
||||
@@ -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
|
||||
@@ -62,3 +66,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
|
||||
return buildstep.SeatTools(Seat, "image", p)
|
||||
}
|
||||
|
||||
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
|
||||
const StepEndTool = "build_image_step_end"
|
||||
|
||||
// stepScan is the module's steps state as buildstep reads it.
|
||||
func (m *Module) stepScan() buildstep.Scan {
|
||||
return buildstep.ScanOf(stdio.State(delivery.StepsState))
|
||||
}
|
||||
|
||||
@@ -155,14 +155,18 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
|
||||
local := localName(workspace, manifest.Module, art.Name)
|
||||
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--tag", local, "--file", dockerfile)
|
||||
// --network pasta: a RUN step's network is pasta's, in user space. buildah's default for a rootless RUN is a
|
||||
// netavark bridge whose nftables rules cannot be applied for an account without a user session, so every RUN
|
||||
// failed before it started ("setup network: netavark: nftables error"; issue 1703, reproduced with buildah 1.45.2).
|
||||
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--network", "pasta", "--tag", local, "--file", dockerfile)
|
||||
if art.Target != "" {
|
||||
argv = append(argv, "--target", art.Target)
|
||||
}
|
||||
argv = append(argv, imageArgs(manifest)...)
|
||||
argv = append(argv, contextDir)
|
||||
if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" {
|
||||
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
m.sayFailure("buildah bud of "+art.Name+" of "+manifest.Module, r)
|
||||
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, cause(r))
|
||||
}
|
||||
defer m.remove(local)
|
||||
digest, err := m.localDigest(ctx, local)
|
||||
@@ -204,8 +208,11 @@ func localName(workspace, module, artifact string) string {
|
||||
func (m *Module) localDigest(ctx context.Context, name string) (string, error) {
|
||||
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "images", "--no-trunc", "--format", "{{.Digest}}", name)...)
|
||||
digest := strings.TrimSpace(r.Stdout)
|
||||
if r.Status != 0 || r.Err != "" || !digestOK.MatchString(digest) {
|
||||
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, cause(r))
|
||||
}
|
||||
if !digestOK.MatchString(digest) {
|
||||
return "", fmt.Errorf("the digest of the image %s could not be read: buildah images answered %q, not a digest", name, cutFront(digest, CauseBytes))
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
@@ -216,17 +223,6 @@ var digestOK = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
func (m *Module) remove(name string) {
|
||||
r := m.Run(m.Ctx, m.env(), "buildah", append(m.storeFlags(), "rmi", "--force", name)...)
|
||||
if (r.Status != 0 || r.Err != "") && m.Say != nil {
|
||||
m.Say("the image %s could not be removed from the store: %s", name, lastLine(r.Err+"\n"+r.Stderr))
|
||||
m.Say("the image %s could not be removed from the store: %s", name, cause(r))
|
||||
}
|
||||
}
|
||||
|
||||
// lastLine is the last line of a command's output that says something: buildah's error is its last line.
|
||||
func lastLine(s string) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if l := strings.TrimSpace(lines[i]); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -69,6 +70,7 @@ func setup(t *testing.T, edit func(string) string) (*Module, *fake, *keptStates)
|
||||
// Dockerfile of the module's own directory and that directory as the context, the target, the pinned base as a build
|
||||
// argument; no mount, no credential file, a runtime directory of its own, and it answers the image's digest.
|
||||
func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "50") // the composer's share (ADR 0553): buildah is given its threads
|
||||
m, f, states := setup(t, same)
|
||||
res, st := runBuild(t, m, states, nil)
|
||||
if st.State != delivery.StepPassed {
|
||||
@@ -83,7 +85,7 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
|
||||
}
|
||||
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
|
||||
for _, want := range []string{
|
||||
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci",
|
||||
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci", "--network pasta",
|
||||
"--file " + dir + "/Dockerfile", "--target final",
|
||||
"--build-arg DEMO_BASE=docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001",
|
||||
} {
|
||||
@@ -110,6 +112,9 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
|
||||
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
|
||||
}
|
||||
if !strings.Contains(env, "GOMAXPROCS=") {
|
||||
t.Errorf("the environment of %q gives buildah no thread limit under a share (ADR 0553): %s", c.line, env)
|
||||
}
|
||||
}
|
||||
// The image is removed from the store once the digest is read, so the store does not grow with every build.
|
||||
if len(f.called("rmi")) != 1 {
|
||||
@@ -117,16 +122,50 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1703c: a failing Dockerfile step fails the step, saying buildah's last line.
|
||||
func TestBuildFailsWithBuildahsLastLine(t *testing.T) {
|
||||
m, f, states := setup(t, same)
|
||||
f.failBud = "Error: building at STEP \"RUN false\": exit status 1"
|
||||
_, st := runBuild(t, m, states, nil)
|
||||
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, `STEP "RUN false"`) {
|
||||
t.Errorf("step = %+v", st)
|
||||
// 1703c, issue 1703: a failing Dockerfile step fails the step, its reason saying buildah's error from stderr, never the
|
||||
// last STEP line buildah writes on stdout; nothing is pushed.
|
||||
func TestBuildFailsWithBuildahsError(t *testing.T) {
|
||||
steps := "STEP 1/3: FROM docker.io/library/alpine\nSTEP 2/3: RUN apk add --no-cache ca-certificates\nfetch https://dl-cdn.alpinelinux.org/x\n"
|
||||
long := strings.Repeat("x", 2*CauseBytes)
|
||||
cases := map[string]struct {
|
||||
ran forgekey.Ran
|
||||
want []string
|
||||
wantNot []string
|
||||
}{
|
||||
"buildah's RUN failure": {forgekey.Ran{Status: 1, Stdout: steps + "STEP 3/3: COPY . /app\n",
|
||||
Stderr: "error running container: from /usr/bin/crun creating container for [/bin/sh -c apk add]: sd-bus call: Interactive authentication required.\nError: building at STEP \"RUN apk add --no-cache ca-certificates\": while running runtime: exit status 1\n"},
|
||||
[]string{"exit status 1", "Interactive authentication required", `building at STEP "RUN apk add`}, []string{"STEP 3/3", "COPY"}},
|
||||
"a failed RUN": {forgekey.Ran{Status: 1, Stdout: steps, Stderr: "Error: building at STEP \"RUN false\": exit status 1\n"},
|
||||
[]string{`STEP "RUN false"`}, []string{"dl-cdn"}},
|
||||
"a long stderr is bounded, its end kept": {forgekey.Ran{Status: 125, Stdout: steps, Stderr: "early noise\n" + long + "\nError: the end\n"},
|
||||
[]string{"Error: the end"}, []string{"early noise", long}},
|
||||
"nothing on stderr": {forgekey.Ran{Status: 2, Stdout: steps}, []string{"exit status 2", "nothing on stderr", "dl-cdn"}, nil},
|
||||
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT", "wrote nothing"}, nil},
|
||||
}
|
||||
if len(f.called("push")) != 0 {
|
||||
t.Error("a failed build pushed")
|
||||
for name, c := range cases {
|
||||
m, f, states := setup(t, same)
|
||||
f.failBud = c.ran
|
||||
_, st := runBuild(t, m, states, nil)
|
||||
if st.State != delivery.StepFailed {
|
||||
t.Errorf("%s: step = %+v, want failed", name, st)
|
||||
continue
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(st.Reason, w) {
|
||||
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
|
||||
}
|
||||
}
|
||||
for _, w := range c.wantNot {
|
||||
if strings.Contains(st.Reason, w) {
|
||||
t.Errorf("%s: the reason %q carries %q", name, st.Reason, w)
|
||||
}
|
||||
}
|
||||
if len(st.Reason) > CauseBytes+200 {
|
||||
t.Errorf("%s: the reason is %d bytes, more than its bound", name, len(st.Reason))
|
||||
}
|
||||
if len(f.called("push")) != 0 {
|
||||
t.Errorf("%s: a failed build pushed", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
// Why a command failed, as the command said it (issue 1703). buildah writes its progress (STEP n/m, the RUN's own
|
||||
// output) on stdout and its error on stderr, so the last line of both together is a STEP line and hides the error:
|
||||
// a failure's reason is stderr's last lines, never stdout's, and the whole tail of stderr is said in the journal.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// CauseBytes bounds what a failure's reason carries of the command's output; TailBytes bounds what the journal gets.
|
||||
const (
|
||||
CauseBytes = 600
|
||||
TailBytes = 4096
|
||||
)
|
||||
|
||||
// cause says why a command failed: its exit status (or why it never ran to one) and the last lines it wrote on stderr,
|
||||
// at most CauseBytes of them, joined with " | ". Only when stderr is empty does stdout's tail stand in, said as such.
|
||||
func cause(r forgekey.Ran) string {
|
||||
head := exitOf(r)
|
||||
if said := tailLines(r.Stderr, CauseBytes, " | "); said != "" {
|
||||
return head + ": " + said
|
||||
}
|
||||
if said := tailLines(r.Stdout, CauseBytes, " | "); said != "" {
|
||||
return head + ", nothing on stderr; stdout ends: " + said
|
||||
}
|
||||
return head + ", and it wrote nothing"
|
||||
}
|
||||
|
||||
// exitOf is how a command ended: why it never ran to an exit status, or the status.
|
||||
func exitOf(r forgekey.Ran) string {
|
||||
if r.Err != "" {
|
||||
return r.Err
|
||||
}
|
||||
return fmt.Sprintf("exit status %d", r.Status)
|
||||
}
|
||||
|
||||
// tailLines is the last non-empty lines of s, trimmed, joined with sep, at most max bytes: the lines are taken whole
|
||||
// from the end while they fit, and when even the last one does not, its end is kept behind "…".
|
||||
func tailLines(s string, max int, sep string) string {
|
||||
var lines []string
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
var keep []string
|
||||
n := 0
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
add := len(lines[i])
|
||||
if len(keep) > 0 {
|
||||
add += len(sep)
|
||||
}
|
||||
if n+add > max {
|
||||
if len(keep) == 0 {
|
||||
keep = append(keep, "…"+cutFront(lines[i], max-len("…")))
|
||||
}
|
||||
break
|
||||
}
|
||||
keep = append([]string{lines[i]}, keep...)
|
||||
n += add
|
||||
}
|
||||
return strings.Join(keep, sep)
|
||||
}
|
||||
|
||||
// cutFront is the last at most max bytes of s, starting on a whole character.
|
||||
func cutFront(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
s = s[len(s)-max:]
|
||||
for len(s) > 0 && !utf8.RuneStart(s[0]) {
|
||||
s = s[1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sayFailure writes the tail of a failed command's stderr (and of its stdout, where buildah's RUN output goes) to the
|
||||
// journal, so what the reason had to leave out can still be read there.
|
||||
func (m *Module) sayFailure(what string, r forgekey.Ran) {
|
||||
m.say("%s failed (%s); stderr ends:\n%s\nstdout ends:\n%s", what, exitOf(r),
|
||||
tailLines(r.Stderr, TailBytes, "\n"), tailLines(r.Stdout, TailBytes, "\n"))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
|
||||
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
|
||||
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
|
||||
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
|
||||
// no slice to size a step to, and no thread limit is set.
|
||||
|
||||
// cpuLimits is what one step is held to now.
|
||||
func (m *Module) cpuLimits() buildcpu.Limits {
|
||||
slots := 1
|
||||
switch {
|
||||
case m.Capacity != nil:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
// threadEnv is the environment (nothing without a share) buildah is given for its threads (it is a Go program). What a build's RUN lines start
|
||||
// runs in the container's own environment, inside the runtime's cgroup: the slice's quota and the nice level bind it.
|
||||
func (m *Module) threadEnv() []string {
|
||||
return buildcpu.Env(m.cpuLimits().Threads)
|
||||
}
|
||||
|
||||
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
|
||||
// to (its unit and its slice), read from its cgroup rather than assumed.
|
||||
func (m *Module) cpuStatus() map[string]any {
|
||||
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
|
||||
}
|
||||
@@ -70,8 +70,8 @@ type fake struct {
|
||||
calls []cmdRecord
|
||||
digest string // what `buildah images` prints
|
||||
pushes int
|
||||
refuse int // the first n pushes answer "connection refused"
|
||||
failBud string
|
||||
refuse int // the first n pushes answer "connection refused"
|
||||
failBud forgekey.Ran // what a failing buildah bud answers; a zero Status is a passing bud
|
||||
// digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one.
|
||||
digestFile string
|
||||
// registry: repository:tag -> digest
|
||||
@@ -116,8 +116,8 @@ func (f *fake) run(ctx context.Context, env []string, name string, args ...strin
|
||||
case name == "buildah" && strings.Contains(line, "--version"):
|
||||
return forgekey.Ran{Stdout: "buildah version 1.45.1 (image-spec 1.1.0, runtime-spec 1.1.0)\n"}
|
||||
case name == "buildah" && strings.Contains(line, " bud "):
|
||||
if f.failBud != "" {
|
||||
return forgekey.Ran{Status: 1, Stderr: "STEP 1/2: FROM x\n" + f.failBud + "\n"}
|
||||
if f.failBud.Status != 0 || f.failBud.Err != "" {
|
||||
return f.failBud
|
||||
}
|
||||
case name == "buildah" && strings.Contains(line, " images "):
|
||||
return forgekey.Ran{Stdout: f.digest + "\n"}
|
||||
|
||||
@@ -203,6 +203,8 @@ func TestARealBuildIsRootlessWithNoSocketAndNoWayOut(t *testing.T) {
|
||||
imageModule(t, m, "FROM "+base+"\nRUN echo b > /proc/sysrq-trigger\n", nil)
|
||||
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
|
||||
t.Errorf("a write to the host's /proc/sysrq-trigger passed: %+v", st)
|
||||
} else if strings.Contains(st.Reason, "setup network") {
|
||||
t.Errorf("the RUN never started, so the write was not what failed (issue 1703): %s", st.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -29,13 +31,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 {
|
||||
@@ -55,7 +59,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
steps := m.StepTools()
|
||||
booted := time.Now()
|
||||
puller := m.Puller(steps, nil)
|
||||
m.Held = puller
|
||||
if m.Node != "" {
|
||||
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
|
||||
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
|
||||
}
|
||||
if m.Node != "" {
|
||||
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
|
||||
go func() { _ = puller.Pull(ctx) }()
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -22,8 +22,13 @@ type Module struct {
|
||||
// Account and UID are the account the module runs as; Home is its home.
|
||||
Account, UID, Home string
|
||||
// Steps is the Put of the steps state; ReadSteps reads it back for the read tools.
|
||||
Steps buildstep.Put
|
||||
Steps buildstep.Put
|
||||
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
|
||||
Held *buildstep.Puller
|
||||
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)
|
||||
@@ -101,6 +106,7 @@ func (m *Module) Start(ctx context.Context) {
|
||||
m.Say("this machine cannot build images: %v", err)
|
||||
}
|
||||
m.CleanWorkspaces(m.clock())
|
||||
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -125,7 +131,8 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
// Status is what the module reports of itself.
|
||||
func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
l, err := m.look(ctx)
|
||||
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil}
|
||||
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
|
||||
"cpu": m.cpuStatus()}
|
||||
if err != nil {
|
||||
out["why"] = err.Error()
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func (m *Module) storeFlags() []string {
|
||||
// error, and the next look refuses with it.
|
||||
func (m *Module) env() []string {
|
||||
_ = m.ensureRuntimeDir()
|
||||
return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}
|
||||
return append([]string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}, m.threadEnv()...)
|
||||
}
|
||||
|
||||
// runtimeDir is buildah's runtime directory, inside the store.
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection
|
||||
@@ -47,13 +49,15 @@ func (m *Module) push(ctx context.Context, workspace, module, local string, res
|
||||
dest := addr + "/" + repository + ":" + tag
|
||||
digestFile := filepath.Join(m.Work, workspace, "pushed."+tag)
|
||||
argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest)
|
||||
var last forgekey.Ran
|
||||
if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error {
|
||||
r := m.Run(ctx, m.env(), "buildah", argv...)
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return errors.New(lastLine(r.Err + "\n" + r.Stderr + "\n" + r.Stdout))
|
||||
last = m.Run(ctx, m.env(), "buildah", argv...)
|
||||
if last.Status != 0 || last.Err != "" {
|
||||
return errors.New(cause(last))
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
m.sayFailure("buildah push "+dest, last)
|
||||
return nil, fmt.Errorf("pushing %s: %w", dest, err)
|
||||
}
|
||||
var served string
|
||||
|
||||
@@ -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)
|
||||
@@ -77,29 +127,44 @@ type StepLine struct {
|
||||
Started string `json:"started"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
What string `json:"what,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
|
||||
}
|
||||
@@ -115,13 +180,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out := make([]StepLine, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
|
||||
if !s.Ended.IsZero() {
|
||||
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, nil
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
@@ -143,8 +208,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -44,6 +45,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
|
||||
@@ -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.20261026142629-ff81bdf8cc8f
|
||||
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.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/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": [
|
||||
{
|
||||
@@ -33,6 +33,7 @@
|
||||
"build_image_release",
|
||||
"build_image_step",
|
||||
"build_image_steps",
|
||||
"build_image_step_end",
|
||||
"build_image_status",
|
||||
"build_image_health"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
@@ -70,3 +74,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
|
||||
return buildstep.SeatTools(Seat, "Node", p)
|
||||
}
|
||||
|
||||
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
|
||||
const StepEndTool = "build_node_step_end"
|
||||
|
||||
// stepScan is the module's steps state as buildstep reads it.
|
||||
func (m *Module) stepScan() buildstep.Scan {
|
||||
return buildstep.ScanOf(stdio.State(delivery.StepsState))
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
|
||||
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
|
||||
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
|
||||
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
|
||||
// module does, which needs the network.
|
||||
// realMachine gives the tests the real toolchain of the module's own lock and makes three workspaces holding one fixture
|
||||
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1), copied into the test's
|
||||
// own cache: the integration level is given its toolchains read-only and no network, so it is never installed from the
|
||||
// registry here. Where no Node build module has installed one (a node without it, a desk), the test is skipped by
|
||||
// name: an install from the public registry inside a shut-off level only fails, after 70 s of npm's retries.
|
||||
func realMachine(t *testing.T) (*Module, string) {
|
||||
t.Helper()
|
||||
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
|
||||
@@ -44,16 +44,19 @@ func realMachine(t *testing.T) (*Module, string) {
|
||||
run := forgekey.ExecRunnerFor(0)
|
||||
m := &Module{Node: "ace", Recipe: lock, Work: work, Run: run, Ctx: context.Background(),
|
||||
Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) {
|
||||
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
|
||||
return Registry{}, fmt.Errorf("the integration test installs no toolchain from a registry: it has no network")
|
||||
}}}
|
||||
withStore(t, m)
|
||||
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
|
||||
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
|
||||
from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json"))
|
||||
if from == "" {
|
||||
t.Skipf("NOT RUN: no Node toolchain is installed under %s for this account to read (no Node build module on this node); "+
|
||||
"the integration level has no network to install one, so this runs on a node that holds the Node build module", filepath.Join(CacheDir, "toolchain"))
|
||||
}
|
||||
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
|
||||
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
|
||||
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
|
||||
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { // present: opens it, installs nothing
|
||||
t.Fatalf("the toolchain copied from %s cannot be used: %v", from, err)
|
||||
}
|
||||
for _, id := range []string{"w1", "w2"} {
|
||||
for _, id := range []string{"w1", "w2", "w3"} {
|
||||
for p, body := range map[string]string{
|
||||
"modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`,
|
||||
"modules/m/package.json": `{"name":"m","type":"module","scripts":{"test":"node -e \"process.exit(0)\""}}`,
|
||||
@@ -65,10 +68,36 @@ func realMachine(t *testing.T) (*Module, string) {
|
||||
return m, work
|
||||
}
|
||||
|
||||
// 1490/AC2: a fixture TypeScript bundle importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal
|
||||
// digests, and its archive holds the bundled .js, the .serve.mjs launcher and package.json.
|
||||
func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
|
||||
// 1490/AC2, 1491 and issue 1869, in one test so the toolchain is copied once: the real type-check and test script of the
|
||||
// fixture pass the unit level; a package whose test imports its own build output (as mesh-sdk's do) passes the unit level,
|
||||
// the test step emitting it with the real tsc, shut off, before the test script runs; and the fixture TypeScript bundle
|
||||
// importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal digests, its archive holding the bundled
|
||||
// .js, the .serve.mjs launcher and package.json. The unit levels run in w3, so the two builds' workspaces stay alike.
|
||||
func TestTheFixturePassesTheUnitLevelAndBuildsTwiceToOneDigest(t *testing.T) {
|
||||
m, work := realMachine(t)
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/m", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
|
||||
t.Errorf("the fixture's unit level: result = %+v", r)
|
||||
}
|
||||
for p, body := range map[string]string{
|
||||
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
|
||||
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
|
||||
"modules/p/src/index.ts": "export const answer: number = 42;\n",
|
||||
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
|
||||
"test('the build is there', () => assert.equal(answer, 42));\n",
|
||||
} {
|
||||
write(t, filepath.Join(work, "w3", "tree", filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
out, err = m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/p", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
|
||||
t.Errorf("the package importing its build: result = %+v", r)
|
||||
}
|
||||
var digests []string
|
||||
for _, id := range []string{"w1", "w2"} {
|
||||
out, err := m.build(context.Background(), map[string]any{"workspace": id, "module": "modules/m", "artifact": "tools"})
|
||||
@@ -89,43 +118,6 @@ func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1491: the real type-check and test script of the same fixture pass the unit level.
|
||||
func TestTheFixturePassesTheUnitLevel(t *testing.T) {
|
||||
m, _ := realMachine(t)
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1869: a package whose test imports its own build output (as mesh-sdk's do) passes the unit level: the test step
|
||||
// emits it with the real tsc, shut off, before the test script runs.
|
||||
func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)")
|
||||
}
|
||||
m, work := realMachine(t)
|
||||
for p, body := range map[string]string{
|
||||
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
|
||||
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
|
||||
"modules/p/src/index.ts": "export const answer: number = 42;\n",
|
||||
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
|
||||
"test('the build is there', () => assert.equal(answer, 42));\n",
|
||||
} {
|
||||
write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644)
|
||||
}
|
||||
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/p", "level": "unit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
|
||||
t.Errorf("result = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
|
||||
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
|
||||
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
|
||||
|
||||
@@ -267,3 +267,38 @@ func TestTheLaunchersAreTheSDKsRule(t *testing.T) {
|
||||
t.Errorf("launchers %v, mesh-sdk's rule %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// issue 1703: a failed command's reason keeps stderr's end and stdout's start, each bounded, so a long stdout (npm's
|
||||
// progress) never pushes npm's error out, and tsc's first error on stdout stays in.
|
||||
func TestRanFailedKeepsTheError(t *testing.T) {
|
||||
noise := strings.Repeat("npm progress line\n", 200)
|
||||
cases := map[string]struct {
|
||||
ran forgekey.Ran
|
||||
want []string
|
||||
wantNot []string
|
||||
}{
|
||||
"passed": {forgekey.Ran{Stdout: "ok"}, nil, nil},
|
||||
"npm, long stdout": {forgekey.Ran{Status: 1, Stdout: noise, Stderr: "npm ERR! early\n" + noise + "npm ERR! code E404\n"}, []string{"status 1", "npm ERR! code E404"}, []string{"npm ERR! early"}},
|
||||
"tsc on stdout": {forgekey.Ran{Status: 2, Stdout: "src/a.ts(1,7): error TS2322: wrong\n" + noise}, []string{"error TS2322"}, nil},
|
||||
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT"}, nil},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := ranFailed(c.ran)
|
||||
if c.want == nil && got != "" {
|
||||
t.Errorf("%s: %q, want none", name, got)
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("%s: %q lacks %q", name, got, w)
|
||||
}
|
||||
}
|
||||
for _, w := range c.wantNot {
|
||||
if strings.Contains(got, w) {
|
||||
t.Errorf("%s: %q carries %q", name, got, w)
|
||||
}
|
||||
}
|
||||
if len(got) > failedBytes+100 {
|
||||
t.Errorf("%s: %d bytes, past its bound", name, len(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Its integration tests copy the Node toolchain the Node build module installed on this node and run node and npm
|
||||
# (realMachine, build_integration_test.go): they run only where the node holds node-build-node (issue 890).
|
||||
node-build-node
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/buildcpu"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
)
|
||||
|
||||
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
|
||||
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
|
||||
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
|
||||
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
|
||||
// no slice to size a step to, and no thread limit is set.
|
||||
|
||||
// cpuLimits is what one step is held to now.
|
||||
func (m *Module) cpuLimits() buildcpu.Limits {
|
||||
slots := 1
|
||||
switch {
|
||||
case m.Capacity != nil:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
// threadEnv is the environment (nothing without a share) every command of a step is given for its threads: GOMAXPROCS for esbuild and every Go
|
||||
// program, UV_THREADPOOL_SIZE for Node's worker pool.
|
||||
func (m *Module) threadEnv() []string {
|
||||
return buildcpu.Env(m.cpuLimits().Threads)
|
||||
}
|
||||
|
||||
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
|
||||
// to (its unit and its slice), read from its cgroup rather than assumed.
|
||||
func (m *Module) cpuStatus() map[string]any {
|
||||
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, grou
|
||||
func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) {
|
||||
r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json")
|
||||
if r.Status != 0 || r.Err != "" {
|
||||
return nil, nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
|
||||
return nil, nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
|
||||
}
|
||||
packages = map[string]bool{}
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -225,6 +226,7 @@ func mustReadDir(t *testing.T, dir string) []string {
|
||||
// are given PATH, HOME and the locale and their own words; and the real clean runner, run on `env` here, prints only
|
||||
// those.
|
||||
func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) {
|
||||
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
|
||||
t.Setenv("MESH_BUS_CREDENTIAL", "hunter2")
|
||||
t.Setenv("NPM_TOKEN", "hunter3")
|
||||
files := typeScriptModule(`{"dependencies":{"lodash":"^4"}}`)
|
||||
@@ -245,6 +247,11 @@ func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) {
|
||||
if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") || c.env[2] != "LC_ALL=C" {
|
||||
t.Errorf("%s was given %v, want PATH, HOME and the locale first", c.name, c.env)
|
||||
}
|
||||
// And its threads, its part of the node's share the composer gave (ADR 0553).
|
||||
if !slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "GOMAXPROCS=") }) ||
|
||||
!slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "UV_THREADPOOL_SIZE=") }) {
|
||||
t.Errorf("%s was given %v, without its threads", c.name, c.env)
|
||||
}
|
||||
}
|
||||
// The runner main wires: what it hands a command is exactly what the step gave.
|
||||
ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env")
|
||||
|
||||
@@ -35,7 +35,7 @@ func (m *Module) isolation(root string, write bool) buildstep.Isolation {
|
||||
ReadOnly: []string{m.toolchainDir()},
|
||||
Caches: []string{m.npmCache()},
|
||||
WriteCaches: write,
|
||||
Env: []string{"PATH=" + pathOrDefault(), "LC_ALL=C"},
|
||||
Env: append([]string{"PATH=" + pathOrDefault(), "LC_ALL=C"}, m.threadEnv()...),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -46,6 +48,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),
|
||||
@@ -67,7 +71,13 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
steps := m.StepTools()
|
||||
booted := time.Now()
|
||||
puller := m.Puller(steps, nil)
|
||||
m.Held = puller
|
||||
if m.Node != "" {
|
||||
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
|
||||
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
|
||||
}
|
||||
if m.Node != "" {
|
||||
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
|
||||
go func() { _ = puller.Pull(ctx) }()
|
||||
|
||||
@@ -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"}) {
|
||||
|
||||
@@ -42,7 +42,12 @@ type Module struct {
|
||||
// Toolchains and Steps are the Put of the states of those names.
|
||||
Toolchains, Steps buildstep.Put
|
||||
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
|
||||
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
|
||||
Held *buildstep.Puller
|
||||
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
|
||||
@@ -134,6 +139,7 @@ func (m *Module) Start(ctx context.Context) {
|
||||
m.Say("the toolchain: %v", err)
|
||||
}
|
||||
m.CleanWorkspaces(m.now())
|
||||
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
@@ -199,7 +205,8 @@ func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
err := m.Check(ctx)
|
||||
installed, _ := m.Probe(ctx)
|
||||
r := m.recipe()
|
||||
out := map[string]any{"node": m.Node, "recipe": r.Version(), "installed": installed, "refusing": err != nil}
|
||||
out := map[string]any{"node": m.Node, "recipe": r.Version(), "installed": installed, "refusing": err != nil,
|
||||
"cpu": m.cpuStatus()}
|
||||
if m.Toolchain != nil {
|
||||
out["toolchain-dir"] = m.Toolchain.Paths.Dir(r.Version())
|
||||
out["toolchain-installed"] = m.Toolchain.Present(r.Version())
|
||||
@@ -224,7 +231,7 @@ const defaultPath = "/usr/local/bin:/usr/bin:/bin"
|
||||
// files and git configuration) and a fixed locale. A command is given these and its own words, never this process's
|
||||
// environment, which holds the module's bus credentials (ADR 0466 §4).
|
||||
func (m *Module) baseEnv() []string {
|
||||
return []string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}
|
||||
return append([]string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}, m.threadEnv()...)
|
||||
}
|
||||
|
||||
func pathOrDefault() string {
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
|
||||
{Name: "short_probe", Run: run},
|
||||
}, (&Module{}).StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -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)
|
||||
@@ -78,29 +128,44 @@ type StepLine struct {
|
||||
Started string `json:"started"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
What string `json:"what,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
|
||||
}
|
||||
@@ -116,13 +181,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
|
||||
}
|
||||
out := make([]StepLine, 0, len(steps))
|
||||
for _, s := range steps {
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
|
||||
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
|
||||
if !s.Ended.IsZero() {
|
||||
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
|
||||
}
|
||||
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 +195,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 +242,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"
|
||||
|
||||
@@ -94,7 +96,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
|
||||
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
|
||||
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
|
||||
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"})
|
||||
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
|
||||
m := readModule(steps, fakeState{})
|
||||
out, err := tool(t, m, StepsReadTool)(map[string]any{})
|
||||
@@ -109,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
|
||||
if !slices.Equal(ids, []string{"c", "b", "a"}) {
|
||||
t.Errorf("ids = %v, want c b a", ids)
|
||||
}
|
||||
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
|
||||
if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed {
|
||||
t.Errorf("first = %+v", lines[0])
|
||||
}
|
||||
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
@@ -40,6 +41,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
|
||||
tools = append(tools, t)
|
||||
}
|
||||
tools = append(tools, m.ReadTools()...)
|
||||
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
|
||||
tools = append(tools, m.releaseTool())
|
||||
return append(tools,
|
||||
stdio.Tool{Name: StatusTool,
|
||||
|
||||
@@ -180,14 +180,28 @@ func (m *Module) runIn(ctx context.Context, root string, write bool, dir string,
|
||||
return run(ctx, extra, "env", append([]string{"-C", dir, name}, args...)...)
|
||||
}
|
||||
|
||||
// ranFailed says why a command did not pass, or "" when it did.
|
||||
// ranFailed says why a command did not pass, or "" when it did (issue 1703): its exit status, the end of what it wrote
|
||||
// on stderr (npm's error is its last lines) and the start of what it wrote on stdout (tsc writes its errors there,
|
||||
// first first), each bounded, so a long stdout never pushes stderr's error out of the reason.
|
||||
func ranFailed(r forgekey.Ran) string {
|
||||
if r.Status == 0 && r.Err == "" {
|
||||
return ""
|
||||
}
|
||||
out := strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
|
||||
if len(out) > 1500 {
|
||||
out = out[:1500] + " ..."
|
||||
why := fmt.Sprintf("status %d %s", r.Status, r.Err)
|
||||
if e := strings.TrimSpace(r.Stderr); e != "" {
|
||||
if len(e) > failedBytes/2 {
|
||||
e = "... " + e[len(e)-failedBytes/2:]
|
||||
}
|
||||
why += ": stderr: " + e
|
||||
}
|
||||
return fmt.Sprintf("status %d %s: %s", r.Status, r.Err, out)
|
||||
if o := strings.TrimSpace(r.Stdout); o != "" {
|
||||
if len(o) > failedBytes/2 {
|
||||
o = o[:failedBytes/2] + " ..."
|
||||
}
|
||||
why += ": stdout: " + o
|
||||
}
|
||||
return why
|
||||
}
|
||||
|
||||
// failedBytes bounds what ranFailed carries of a command's output, stderr and stdout half each.
|
||||
const failedBytes = 1500
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
|
||||
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.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/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": [
|
||||
{
|
||||
@@ -34,6 +34,7 @@
|
||||
"build_node_release",
|
||||
"build_node_step",
|
||||
"build_node_steps",
|
||||
"build_node_step_end",
|
||||
"build_node_toolchains",
|
||||
"build_node_status",
|
||||
"build_node_health"
|
||||
|
||||
@@ -10,19 +10,22 @@ Each directory or archive this module declares says how it treats a file it did
|
||||
|
||||
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
## A backup that failed before the send
|
||||
## What it keeps (ADR 0539 §17)
|
||||
|
||||
The controller backs up the data of the modules a delivery moves before it asks or sends anything (ADR 0404). When its
|
||||
walk's record, in `plan-moved`, names a backup that failed or stayed silent and nothing of the plan was sent, the
|
||||
delivery goes from delivering to held (the observed row `backup failed`); the held reason names the machine, the
|
||||
module and the backup's words, and the pull request's comment shows it. The hold does not lift by itself. `release`
|
||||
of such a delivery asks the controller (`delivery-backups-again`) to take the backups again and the delivery goes on
|
||||
delivering; a record from before the controller cleared the failure does not hold it again. A held delivery already
|
||||
ends as covered or delivered when a later merge covers it.
|
||||
The gate keeps the merge's states: proposed, ready, rejected, merged, held (a merge that reached the trunk without a passing
|
||||
check) and closed. It asks the operator's Approve and merges once, moving `source.main`. It asks the controller nothing, follows
|
||||
no plan event and derives nothing after the merge: whether a merged change reached the machines is the view's, from
|
||||
`source.main`, the wants and the haves. The seat `mesh-delivery` keeps the verbs `deliveries`, `show`, `groups`, `checks`,
|
||||
`table`, `stalled` (for a merge or a check past its bound), `recheck` and `close`; `times`, `what-if`, `release`, `stop` and
|
||||
`retire-history` went with the plans, so the gate stays a bundle serving the seat. A delivery an earlier owner kept in a state that
|
||||
is gone is read as the state it came to (merged, closed or proposed) when the gate starts.
|
||||
|
||||
## 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
|
||||
@@ -33,23 +36,16 @@ names every refusal and rejection at once, and the pull request is told at once;
|
||||
`needs: new-head` and names its author: the branch, and the session a `Pushed-by: <who@node>` line in the pull
|
||||
request's description names.
|
||||
|
||||
## Taking the seat over from today's module
|
||||
## A merge the forge account cannot carry out is not asked (issue 1945)
|
||||
|
||||
The gate claims the seat `mesh-delivery` and took it over from the module `mesh-delivery`, which the catalogue no longer holds.
|
||||
The controller refuses a merge that removes a module that is assigned, so today's module stayed in the catalogue until it was
|
||||
unassigned, and went by a handover of verbs after the merge, never by hand on a machine (novox/hq ADR 0449 §4, ADR 0450 §6, ADR 0454):
|
||||
Before the operator's Approve is asked, the gate reads through `gitea_commit_statuses` whether the forge's merging
|
||||
account writes each repository the ask covers (`merge.writes`). Where it does not, nothing is asked: the delivery waits
|
||||
on "the forge account cannot write <repo>: gitea_grant_write" (`show`), raised at once as a condition. The read holds
|
||||
until the gitea module's `gitea.repo.writable` (its grant, read back) or ten minutes, then the ask is made again; an
|
||||
approved merge of that repository the forge refused is asked again on the same event (`writes.go`).
|
||||
|
||||
1. assign `mesh-delivery-gate` to the machine that holds the seat (`mesh-controller.assign`);
|
||||
2. check that `mesh-controller.seats` still names `mesh-delivery`, held by the running module;
|
||||
3. unassign `mesh-delivery` from that machine (`mesh-controller.unassign`) and push it (`mesh-controller.push`);
|
||||
4. the seat passes: the gate reads the controller's assignments and the node-engine's have keys, finds the module on no machine,
|
||||
carries its deliveries, groups and pull keys over once (the key `carried` in its `deliveries` says it did), reads the controller's
|
||||
walks and the forge's open pull requests, and acts.
|
||||
## The seat's hand-over
|
||||
|
||||
Until then the gate is silent: it takes no event, writes no key, asks nothing and answers no verb but `delivery_status`, which
|
||||
says why (`holds-the-seat`, `seat-waits`).
|
||||
|
||||
The removal of `modules/mesh-delivery` followed the handover as a pull request of its own, merged by the gate (ADR 0454); the
|
||||
catalogue has one claimant of the seat, and no manifest but the gate's and the issue tracker's names the old module but the names
|
||||
the handover needed: the three buckets the gate carries over, and the event the issue tracker and the tool runner hear
|
||||
(`catalogue_test.go`).
|
||||
The gate took the seat `mesh-delivery` over from the module `mesh-delivery` (ADR 0449 §4, ADR 0450 §6, ADR 0454) and carried its
|
||||
deliveries, groups and pull keys once; the carry, and the reading of the controller's assignments and the node-engine's have keys
|
||||
that waited for it, went with release 6. `catalogue_test.go` keeps the catalogue to one claimant of the seat.
|
||||
|
||||
@@ -1,50 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// One build per change (novox/hq ADR 0350): a pull request closed unmerged has its check builds let go of, and a
|
||||
// walk's record says per module whether its build reused a check's build or built, and why.
|
||||
const (
|
||||
cat = "novox/mesh-catalog"
|
||||
headCC = "cc00000000000001"
|
||||
headDu = "dd00000000000002"
|
||||
)
|
||||
|
||||
// A pull request closed unmerged is stopped. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
|
||||
// of what its checks built (§5's forget-checks goes with the merge gate's checks); a controller that is down changes
|
||||
// nothing.
|
||||
func TestAPullRequestClosedUnmergedIsStoppedAndAsksTheControllerNothing(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), atCC)
|
||||
var atCC = time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// A pull request closed unmerged is closed. Since issue 1318 (ADR 0503 §3) the controller is no longer asked to let go
|
||||
// of what its checks built, and since ADR 0539 §17 the gate asks it nothing at all.
|
||||
func TestAPullRequestClosedUnmergedIsClosedAndAsksNoOne(t *testing.T) {
|
||||
w := newWorldOver(t, newMemStore(), newFakeForge(), atCC)
|
||||
cc := pr(cat, 101, headCC, "feat/claude-code", "modules/claude-code/module.json")
|
||||
w.h.PullUpdated(cc)
|
||||
w.h.PullClosed(cc)
|
||||
if len(w.ctl.forgot) != 0 {
|
||||
t.Fatalf("closed unmerged, the controller was asked to let go of %v", w.ctl.forgot)
|
||||
}
|
||||
if s := w.state(IDOf(cat, headCC)); s != Stopped {
|
||||
if s := w.state(IDOf(cat, headCC)); s != Closed {
|
||||
t.Fatalf("closed unmerged it is %s", s)
|
||||
}
|
||||
|
||||
w.ctl.down = true
|
||||
du := pr(cat, 102, headDu, "feat/dunst", "modules/dunst/module.json")
|
||||
w.h.PullUpdated(du)
|
||||
w.h.PullClosed(du)
|
||||
if s := w.state(IDOf(cat, headDu)); s != Stopped {
|
||||
t.Fatalf("with the controller down, a closed delivery is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The walk's record says per module what its build did, and the delivery keeps it beside its walk (§4).
|
||||
func TestAWalksRecordSaysWhetherEachModuleWasReusedOrBuilt(t *testing.T) {
|
||||
walk := catWalk("plan-1", mergeCC, atCC.Add(time.Minute), "claude-code", "dunst")
|
||||
walk.Modules["claude-code"].Made = "reused from the check of novox/mesh-catalog#101 at head cccccccccccc " +
|
||||
"(merged into trunk 553b71910000): tools sha256:aa"
|
||||
walk.Modules["dunst"].Made = "built: no check of novox/mesh-catalog#101 built it"
|
||||
seen := walk.Seen()
|
||||
if !strings.HasPrefix(seen.Built["claude-code"], "reused from the check of novox/mesh-catalog#101") ||
|
||||
seen.Built["dunst"] != "built: no check of novox/mesh-catalog#101 built it" {
|
||||
t.Fatalf("the delivery keeps %v", seen.Built)
|
||||
}
|
||||
if catWalk("plan-2", mergeCC, atCC, "dunst").Seen().Built != nil {
|
||||
t.Fatal("a walk from a controller that says nothing of its builds reads as if it said something")
|
||||
if s := w.state(IDOf(cat, headDu)); s != Closed {
|
||||
t.Fatalf("a closed delivery is %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The tests of novox/hq ADR 0505 "How it is checked" for the gate: a refusal the gate is already answering is no
|
||||
@@ -39,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")
|
||||
}
|
||||
@@ -76,7 +82,7 @@ func refusedForAMovedTrunk(t *testing.T) (*world, string) {
|
||||
t.Helper()
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = errAskedAgain
|
||||
approve(w)
|
||||
if len(w.forge.merges) != 1 || w.delivery(id).Ready.RefusedSince == nil {
|
||||
@@ -125,7 +131,7 @@ func TestATrunkMovedRefusalIsStalledAtTwiceTheLastCheckTimeAtLeastTheFloor(t *te
|
||||
func TestAnyOtherRefusalIsStalledAtOnce(t *testing.T) {
|
||||
w := announced(t)
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
w.forge.refuse = refusedWords("novox/app#7")
|
||||
approve(w)
|
||||
if got := linesFor(w, id); len(got) != 1 {
|
||||
@@ -146,7 +152,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
w.h.PullUpdated(p)
|
||||
w.settleAll()
|
||||
id := IDOf("novox/app", readyHead)
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
|
||||
// Judged again (the table's recheck, which the checker's verdict on a new trunk makes): proposed once more.
|
||||
w.h.mu.Lock()
|
||||
d := w.h.deliveries[id]
|
||||
@@ -156,7 +162,7 @@ func rejectedAfterItsApprove(t *testing.T, pushedBy string) (*world, string) {
|
||||
}
|
||||
d.Check = nil
|
||||
w.h.mu.Unlock()
|
||||
hear(t, w, ControllerSeat+".checked", judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
hear(t, w, shimCheckedKey, judgedAt("novox/app", 7, readyHead, "fail", "t1t1t1t1t1t1"))
|
||||
approve(w)
|
||||
w.h.React()
|
||||
if w.state(id) != Rejected {
|
||||
@@ -220,48 +226,3 @@ func TestANewHeadWithinTwoHoursKeepsTheRejectedHeadFromTheOperator(t *testing.T)
|
||||
t.Fatalf("the superseded head reached the operator: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0505 §2: a walk's end said again by the controller with its declared machines (`declared_read`, a touch marked
|
||||
// declared) acts once: no transition, comment or ask of the controller the first saying did not make.
|
||||
func TestAWalkSaidAgainWithItsDeclaredMachinesActsOnceInTheGate(t *testing.T) {
|
||||
w, id, plan := aDeliveringWalk(t)
|
||||
done := w.ctl.walk(plan)
|
||||
sent := time.Date(2026, 10, 6, 12, 40, 0, 0, time.UTC)
|
||||
done.Modules["app"] = &WalkModule{State: "built", First: []string{"anchor"}, FirstAt: &sent, SentAt: &sent, Build: "build-1",
|
||||
Gate: &WalkGate{Machines: []string{"anchor"}, Since: &sent, Passes: 1, Verdict: "passed", JudgedAt: &sent}}
|
||||
done.State = walkDone
|
||||
done = w.ctl.put(done)
|
||||
body := func(again bool) []byte {
|
||||
raw, err := json.Marshal(done)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var said map[string]any
|
||||
_ = json.Unmarshal(raw, &said)
|
||||
said["walk"], said["phase"], said["outcome"] = plan, "ended", "delivered"
|
||||
said["touches"] = []map[string]any{{"machine": "anchor", "modules": []string{"app"}}}
|
||||
if again {
|
||||
said["touches"] = append(said["touches"].([]map[string]any),
|
||||
map[string]any{"machine": "shanks", "modules": []string{}, "declared": true})
|
||||
said["declared_read"] = true
|
||||
}
|
||||
out, _ := json.Marshal(said)
|
||||
return out
|
||||
}
|
||||
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(false)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if s := w.state(id); s != Delivered {
|
||||
t.Fatalf("the walk's end left it %s", s)
|
||||
}
|
||||
transitions, comments, delivers := len(w.delivery(id).Transitions), len(w.forge.comments), len(w.ctl.delivers)
|
||||
if err := take(w.h, stdio.Envelope{Key: ControllerSeat + ".plan-moved", Body: body(true)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.settleAll()
|
||||
if len(w.delivery(id).Transitions) != transitions || len(w.forge.comments) != comments || len(w.ctl.delivers) != delivers {
|
||||
t.Fatalf("said again it acted again: transitions %d→%d, comments %d→%d, asks of the controller %d→%d",
|
||||
transitions, len(w.delivery(id).Transitions), comments, len(w.forge.comments), delivers, len(w.ctl.delivers))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user