Keep no secret-shaped question, and hold the two-question cap across nodes (issue 589, review 105)
mesh/merge-gate head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
A question was kept on the bus before the router judged its words, so a token in it reached a bucket every node reads: every word, the options' too, is now held to the secret shapes and invisible characters before anything is kept, and a refused question keeps none of its words. The cap was check-then-create and failed open: each open question now holds one of two slot keys taken by create or compare-and-set, refusing when a slot cannot be read. A question the router never recorded frees its slot after two minutes instead of at expiry. by is required; wording matches ADR 0332.
This commit is contained in:
@@ -6,10 +6,13 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
@@ -27,7 +30,7 @@ func mergeTonight() QuestionInput {
|
||||
}
|
||||
}
|
||||
|
||||
func askMergeTonight(t *testing.T, p Paths, store *memProposals, r *router, at time.Time) string {
|
||||
func askMergeTonight(t *testing.T, p Paths, store *memQuestions, r *router, at time.Time) string {
|
||||
t.Helper()
|
||||
out, err := AskQuestion(p, mergeTonight(), store, r, at)
|
||||
if err != nil {
|
||||
@@ -38,7 +41,7 @@ func askMergeTonight(t *testing.T, p Paths, store *memProposals, r *router, at t
|
||||
|
||||
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,7 +81,7 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
|
||||
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
|
||||
if err != nil || out["state"] != AnswerWaiting {
|
||||
@@ -100,7 +103,7 @@ func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||
|
||||
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
w := r.chose(key, "o1", t0.Add(time.Minute))
|
||||
|
||||
@@ -115,7 +118,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
}
|
||||
|
||||
// A warrant for another ask's digest.
|
||||
store2, r2 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store2, r2 := newMemQuestions(), newRouter()
|
||||
key2 := askMergeTonight(t, p, store2, r2, t0)
|
||||
w2 := r2.chose(key2, "o1", t0.Add(time.Minute))
|
||||
w2.AskDigest = "sha256:0000"
|
||||
@@ -125,7 +128,7 @@ func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
}
|
||||
|
||||
// A choice made after the question expired.
|
||||
store3, r3 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store3, r3 := newMemQuestions(), newRouter()
|
||||
key3 := askMergeTonight(t, p, store3, r3, t0)
|
||||
r3.close(r3.chose(key3, "o1", t0.Add(QuestionDefault+time.Minute)))
|
||||
if out, _ := ReadAnswer(store3, r3, key3, t0.Add(2*QuestionDefault)); out["state"] != AnswerNotProven {
|
||||
@@ -150,8 +153,19 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
|
||||
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
|
||||
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
|
||||
"invisible label": func(in *QuestionInput) { in.Options[0].Label = "Merge\u200bnow" },
|
||||
"invisible means": func(in *QuestionInput) { in.Options[1].Means += "\u202e" },
|
||||
"a token, context": func(in *QuestionInput) { in.Context += " The key is ghp_abcdefghijklmnopqrstuvwxyz0123456789." },
|
||||
"a token, headline": func(in *QuestionInput) { in.Headline = "Use sk-abcdefghijklmnopqrstuv?" },
|
||||
"a token, why": func(in *QuestionInput) { in.Why = "password: hunter2 works" },
|
||||
"a token, expiry": func(in *QuestionInput) { in.OnExpiry = "I keep AKIAABCDEFGHIJKLMNOP" },
|
||||
"a token, label": func(in *QuestionInput) { in.Options[0].Label = "token=abc" },
|
||||
"a token, means": func(in *QuestionInput) { in.Options[1].Means = "-----BEGIN PRIVATE KEY-----" },
|
||||
"no by": func(in *QuestionInput) { in.By = "" },
|
||||
"61 characters": func(in *QuestionInput) { in.Headline = strings.Repeat("x", 60) + "?" },
|
||||
"1431 minutes": func(in *QuestionInput) { in.Expires = 1431 * time.Minute },
|
||||
} {
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
in := mergeTonight()
|
||||
in.Options = append([]QuestionOption(nil), in.Options...)
|
||||
change(&in)
|
||||
@@ -166,7 +180,7 @@ func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
|
||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
|
||||
@@ -187,7 +201,7 @@ func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
// subject, with no record.
|
||||
type questionRefusingRouter struct {
|
||||
*router
|
||||
store ProposalStore
|
||||
store QuestionStore
|
||||
id string
|
||||
}
|
||||
|
||||
@@ -203,7 +217,7 @@ func (r *questionRefusingRouter) Publish(subject string, body any, id string) er
|
||||
|
||||
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := &memProposals{m: map[string]json.RawMessage{}}
|
||||
store := newMemQuestions()
|
||||
refusing := &questionRefusingRouter{router: newRouter(), store: store}
|
||||
within, every := RouterAnswersWithin, RouterAnswersEvery
|
||||
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
|
||||
@@ -216,14 +230,19 @@ func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
|
||||
t.Errorf("read after the refusal: %v %v", out, err)
|
||||
}
|
||||
if open := openQuestions(store, refusing, t0.Add(time.Minute)); len(open) != 0 {
|
||||
t.Errorf("a refused question still counts as waiting: %v", open)
|
||||
if waits, err := questionOpen(store, refusing, refusing.id, t0.Add(time.Minute)); waits || err != nil {
|
||||
t.Errorf("a refused question still counts as waiting: %v %v", waits, err)
|
||||
}
|
||||
var kept Question
|
||||
_ = json.Unmarshal(store.m[refusing.id], &kept)
|
||||
if kept.Headline != "" || kept.Context != "" || len(kept.Options) != 0 || kept.Why != "" || kept.OnExpiry != "" {
|
||||
t.Errorf("a refused question keeps its words: %+v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
|
||||
@@ -235,7 +254,7 @@ func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
r := newRouter()
|
||||
questions := &memProposals{m: map[string]json.RawMessage{}}
|
||||
questions := newMemQuestions()
|
||||
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
|
||||
Write: writer(map[string]string{}), Questions: questions}
|
||||
t.Cleanup(func() { proposing = nil })
|
||||
@@ -271,7 +290,7 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
|
||||
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
|
||||
}
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
most, every := AnswerWaitsAtMost, AnswerLooksEvery
|
||||
AnswerWaitsAtMost, AnswerLooksEvery = 100*time.Millisecond, 10*time.Millisecond
|
||||
@@ -285,3 +304,165 @@ func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
|
||||
t.Errorf("a read asked to wait an hour took %s", took)
|
||||
}
|
||||
}
|
||||
|
||||
// memQuestions is the `questions` state: values with revisions, create-only and compare-and-set, safe for the
|
||||
// goroutines a test starts.
|
||||
type memQuestions struct {
|
||||
mu sync.Mutex
|
||||
m map[string]json.RawMessage
|
||||
rev map[string]uint64
|
||||
n uint64
|
||||
// failGet fails every read of a key with this prefix.
|
||||
failGet string
|
||||
}
|
||||
|
||||
func newMemQuestions() *memQuestions {
|
||||
return &memQuestions{m: map[string]json.RawMessage{}, rev: map[string]uint64{}}
|
||||
}
|
||||
|
||||
func (s *memQuestions) Get(key string) (json.RawMessage, uint64, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.failGet != "" && strings.HasPrefix(key, s.failGet) {
|
||||
return nil, 0, false, errors.New("the bus did not answer")
|
||||
}
|
||||
raw, ok := s.m[key]
|
||||
return raw, s.rev[key], ok, nil
|
||||
}
|
||||
func (s *memQuestions) put(key string, value any) error {
|
||||
raw, err := json.Marshal(value)
|
||||
s.n++
|
||||
s.m[key], s.rev[key] = raw, s.n
|
||||
return err
|
||||
}
|
||||
func (s *memQuestions) Create(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.m[key]; ok {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memQuestions) Update(key string, value any, revision uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.rev[key] != revision {
|
||||
return stdio.ErrChanged
|
||||
}
|
||||
return s.put(key, value)
|
||||
}
|
||||
func (s *memQuestions) Put(key string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.put(key, value)
|
||||
}
|
||||
|
||||
// The edges of ADR 0332 §2 that are allowed: a 60-character headline and 1430 minutes.
|
||||
func TestTheEdgesOfAQuestionAreAsked(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
in := mergeTonight()
|
||||
in.Headline = strings.Repeat("x", 59) + "?"
|
||||
in.Expires = 1430 * time.Minute
|
||||
if _, err := AskQuestion(p, in, newMemQuestions(), newRouter(), t0); err != nil {
|
||||
t.Fatalf("a 60-character headline lasting 1430 minutes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A warrant naming another asker or another ask, or an option the question did not offer, is no answer to it.
|
||||
func TestAWarrantForAnotherAskOrOptionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
for name, change := range map[string]func(*asks.Warrant){
|
||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
||||
"another ask": func(w *asks.Warrant) { w.Ask = "q-0000000000000000" },
|
||||
"an option unoffered": func(w *asks.Warrant) { w.Option, w.Label = "o9", "Delete everything" },
|
||||
} {
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
w := r.chose(key, "o1", t0.Add(time.Minute))
|
||||
change(&w)
|
||||
r.records[key], _ = json.Marshal(map[string]any{"state": "chosen", "warrant": w})
|
||||
if out, _ := ReadAnswer(store, r, key, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
|
||||
t.Errorf("%s read as %v", name, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two nodes asking at the same moment take two slots at most: of three sessions racing, one is refused.
|
||||
func TestTheCapHoldsAcrossNodesAskingAtOnce(t *testing.T) {
|
||||
store, r := newMemQuestions(), &lockedRouter{router: newRouter()}
|
||||
nodes := []string{"g14", "shanks", "ace"}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(nodes))
|
||||
for i, name := range nodes {
|
||||
p, _ := node(t, name)
|
||||
wg.Add(1)
|
||||
go func(i int, p Paths) {
|
||||
defer wg.Done()
|
||||
in := mergeTonight()
|
||||
in.By = "fix-589"
|
||||
_, errs[i] = AskQuestion(p, in, store, r, t0)
|
||||
}(i, p)
|
||||
}
|
||||
wg.Wait()
|
||||
refused := 0
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "questions already wait") {
|
||||
t.Errorf("refused for another reason: %v", err)
|
||||
}
|
||||
refused++
|
||||
}
|
||||
}
|
||||
if refused != 1 {
|
||||
t.Errorf("%d of three questions asked at once were refused; one is", refused)
|
||||
}
|
||||
}
|
||||
|
||||
// A slot that cannot be read refuses the question: the cap fails closed.
|
||||
func TestTheCapFailsClosed(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
store.failGet = "slot-"
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "could not be counted") {
|
||||
t.Fatalf("asked with the slots unreadable: %v", err)
|
||||
}
|
||||
if len(r.subjects) != 0 {
|
||||
t.Error("the operator was asked anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// A question whose refusal was not heard here, and of which the router holds no record, frees its slot once the
|
||||
// router has had time to record it, and reads as ended — not open until it expires.
|
||||
func TestAQuestionTheRouterNeverHeldFreesItsSlot(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := newMemQuestions(), newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
delete(r.records, first) // refused, and the refusal never heard here
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(time.Minute)); err == nil {
|
||||
t.Fatal("a slot was freed before the router had time to record the question")
|
||||
}
|
||||
if out, _ := ReadAnswer(store, r, first, t0.Add(RouterHoldsWithin)); out["state"] != AnswerEnded {
|
||||
t.Errorf("read as %v", out)
|
||||
}
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(RouterHoldsWithin)); err != nil {
|
||||
t.Errorf("the slot of a question the router never held was not freed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// lockedRouter is the router fake, safe for sessions asking at once.
|
||||
type lockedRouter struct {
|
||||
mu sync.Mutex
|
||||
*router
|
||||
}
|
||||
|
||||
func (l *lockedRouter) Publish(subject string, body any, id string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Publish(subject, body, id)
|
||||
}
|
||||
func (l *lockedRouter) Record(id string) (*stdio.StateEntry, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.router.Record(id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user