mesh-mail: refuse a send whose undecided index cannot be written, and never overwrite a corrupt index (issue 811, review round 3)
mesh/merge-gate head eb97a3d9a2d7 merged into trunk 0914a33fba5a — pass: builds disk-load, mesh-delivery, mesh-mail, rofi → ace, g14, novox, shanks; no bus…
mesh/repo-check head eb97a3d9a2d7 merged into trunk 0914a33fba5a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group feat/mail-hardening delivered: every member is delivered

This commit is contained in:
jochen
2026-10-15 15:40:16 +02:00
parent 8b2e2300d8
commit eb97a3d9a2
2 changed files with 69 additions and 12 deletions
+18 -12
View File
@@ -63,9 +63,9 @@ type undecidedIndex struct {
IDs []string `json:"ids"`
}
// addUndecided adds id to the undecided index, retrying its compare-and-set write on a race. Best-effort: the send
// itself is already kept by the time this is called, so a failure here costs the sweep sight of it, never the ask
// (mail_send_status and a direct Heard still decide it).
// addUndecided adds id to the undecided index, retrying its compare-and-set write on a race. A stored index that
// cannot be parsed is never overwritten (which would silently drop every id it already named): it is reported as
// an error naming the key instead (novox/hq issue 811, review round 3).
func addUndecided(store SendStore, id string) error {
for {
raw, rev, ok, err := store.Get(undecidedIndexKey)
@@ -74,8 +74,8 @@ func addUndecided(store SendStore, id string) error {
}
var idx undecidedIndex
if ok {
if json.Unmarshal(raw, &idx) != nil {
idx = undecidedIndex{}
if err := json.Unmarshal(raw, &idx); err != nil {
return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err)
}
for _, existing := range idx.IDs {
if existing == id {
@@ -104,7 +104,8 @@ func addUndecided(store SendStore, id string) error {
}
// removeUndecided drops id from the undecided index, retrying its compare-and-set write on a race. A send never
// read by the index (already absent) is left as it is.
// read by the index (already absent) is left as it is. A stored index that cannot be parsed is never overwritten:
// it is reported as an error naming the key instead (novox/hq issue 811, review round 3).
func removeUndecided(store SendStore, id string) error {
for {
raw, rev, ok, err := store.Get(undecidedIndexKey)
@@ -112,8 +113,8 @@ func removeUndecided(store SendStore, id string) error {
return err
}
var idx undecidedIndex
if json.Unmarshal(raw, &idx) != nil {
return nil
if err := json.Unmarshal(raw, &idx); err != nil {
return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err)
}
at := -1
for i, existing := range idx.IDs {
@@ -136,15 +137,16 @@ func removeUndecided(store SendStore, id string) error {
}
}
// undecidedIDs is every id the undecided index currently names, for the sweep to read.
// undecidedIDs is every id the undecided index currently names, for the sweep to read. An index that cannot be
// parsed is reported as an error naming the key, not silently read as empty (novox/hq issue 811, review round 3).
func undecidedIDs(store SendStore) ([]string, error) {
raw, _, ok, err := store.Get(undecidedIndexKey)
if err != nil || !ok {
return nil, err
}
var idx undecidedIndex
if json.Unmarshal(raw, &idx) != nil {
return nil, nil
if err := json.Unmarshal(raw, &idx); err != nil {
return nil, fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err)
}
return idx.IDs, nil
}
@@ -374,7 +376,11 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[
if _, err := store.Create(req.ID, req); err != nil {
return nil, fmt.Errorf("the send could not be kept, so the operator was not asked: %w. Nothing was sent", err)
}
_ = addUndecided(store, req.ID) // best-effort (see addUndecided); the send is already kept either way
if err := addUndecided(store, req.ID); err != nil {
// Nothing was published yet, so refusing here costs nothing: an ask the sweep could never see is never
// sent (novox/hq issue 811, review round 3).
return nil, fmt.Errorf("the send could not be made visible to the sweep, so the operator was not asked: %w. Nothing was sent", err)
}
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
return nil, fmt.Errorf("the operator could not be asked (%v). Nothing was sent; mail_send_status %s says so", err, req.ID)
}
@@ -28,6 +28,11 @@ type memSends struct {
vals map[string]json.RawMessage
revs map[string]uint64
n uint64
// failKey/failErr: when failKey matches, Create and Update report failErr instead of writing, for a test to
// simulate a store whose index write fails (novox/hq issue 811, review round 3).
failKey string
failErr error
}
func newMemSends() *memSends {
@@ -46,6 +51,9 @@ func (m *memSends) Get(key string) (json.RawMessage, uint64, bool, error) {
func (m *memSends) Create(key string, value any) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.failErr != nil && key == m.failKey {
return 0, m.failErr
}
if _, ok := m.vals[key]; ok {
return 0, stdio.ErrChanged
}
@@ -54,6 +62,9 @@ func (m *memSends) Create(key string, value any) (uint64, error) {
func (m *memSends) Update(key string, value any, revision uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.failErr != nil && key == m.failKey {
return 0, m.failErr
}
if revision == 0 || m.revs[key] != revision {
return 0, stdio.ErrChanged
}
@@ -692,3 +703,43 @@ func TestSweepReadsOnlyUndecidedSends(t *testing.T) {
t.Fatalf("the sweep never read the undecided send's router record")
}
}
// (12) When the undecided index cannot be written, mail_send refuses the send and publishes no ask: nothing was
// published yet, so refusing costs nothing, and an ask the sweep could never see is never sent (novox/hq issue
// 811, review round 3).
func TestMailSendRefusesWhenTheUndecidedIndexCannotBeWritten(t *testing.T) {
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
sends.failKey, sends.failErr = undecidedIndexKey, errors.New("the store is down")
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if !isErr {
t.Fatalf("mail_send did not report the undecided index failing to write: %s", text)
}
if n := router.published1(); n != 0 {
t.Fatalf("an ask was published despite the undecided index failing to write: %d", n)
}
}
// (13) An undecided index that cannot be parsed is never overwritten: addUndecided and removeUndecided fail,
// naming the key, and leave the stored bytes as they were, instead of resetting the index to empty (novox/hq
// issue 811, review round 3).
func TestAddAndRemoveUndecidedFailOnACorruptIndexRatherThanResettingIt(t *testing.T) {
sends := newMemSends()
sends.vals[undecidedIndexKey] = json.RawMessage(`not json`)
sends.revs[undecidedIndexKey] = 1
if err := addUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) {
t.Fatalf("addUndecided did not fail naming the key on a corrupt index: %v", err)
}
if string(sends.vals[undecidedIndexKey]) != "not json" {
t.Fatalf("addUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey])
}
if err := removeUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) {
t.Fatalf("removeUndecided did not fail naming the key on a corrupt index: %v", err)
}
if string(sends.vals[undecidedIndexKey]) != "not json" {
t.Fatalf("removeUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey])
}
}