mesh-mail: decide kept by settleSend's own returned revision, not its error, so a changed revision is actually retried instead of silently accepted as another writer's state; emit mail.send.failed from claimQueuedSend only once its own write lands (review round 2; issue 1198)

This commit is contained in:
jochen
2026-10-19 11:07:24 +02:00
parent 7db4bd215d
commit 06dfce87f5
3 changed files with 141 additions and 12 deletions
+32 -12
View File
@@ -759,11 +759,13 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
// crash between this and the write below must still leave it findable once refused does land;
// tellSend itself tolerates finding it still sending, queued or retrying in the meantime.
indexErr := addTellPending(store, req.ID)
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: " + reason})
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
if err != nil {
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: " + reason})
if newRev == 0 {
// The write did not land (another writer's decision already stands, or it failed outright):
// nothing of this call's own making to emit (novox/hq review round 2, issue 1198).
return req, d, 0, claimRefused, errOr(indexErr, err)
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
// Only once refused is durably recorded is the sender-pending index's entry dropped.
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
@@ -775,11 +777,13 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
// A previous delivery may have handed it to the server already; never sent twice, and never
// retried automatically either way (ADR 0436 clause 10). Same ordering as refuse() above.
indexErr := addTellPending(store, req.ID)
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "not proven", Attempt: req.Decided.Attempt})
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
if err != nil {
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "not proven", Attempt: req.Decided.Attempt})
if newRev == 0 {
// The write did not land: nothing of this call's own making to emit (novox/hq review round 2,
// issue 1198).
return req, d, 0, claimNotProven, errOr(indexErr, err)
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
}
@@ -887,7 +891,12 @@ func recordOutcome(store SendStore, emit Emit, req SendRequest, claimed SendDeci
// fresh one) before giving up: kept is false only when the record's own write could not be kept even
// after that retry, in which case the caller emits nothing and leaves the message to the bus entirely —
// never announcing an outcome (sent, failed, retrying) that was not actually recorded (novox/hq review
// round 1 finding 5).
// round 1 finding 5). "Kept" is decided from settleSend's own returned revision, never from its error
// alone: on a changed revision whose record already carries another writer's decision, settleSend
// (through updateDecision) returns that other decision with revision 0 and a nil error — a nil error
// is not success here, and without checking the revision the retry never ran at all, so the caller
// could emit an outcome (and ack) while the record actually held someone else's state (novox/hq review
// round 2, issue 1198).
write := func(d SendDecision, final, tellWorthy bool) (result SendDecision, kept bool, outErr error) {
var indexErr error
if tellWorthy {
@@ -897,11 +906,18 @@ func recordOutcome(store SendStore, emit Emit, req SendRequest, claimed SendDeci
indexErr = addTellPending(store, req.ID)
}
curReq, curRev := req, rev
var newRev uint64
var writeErr error
for attempt := 0; attempt < 2; attempt++ {
result, _, writeErr = settleSend(store, curReq, curRev, d)
if writeErr == nil || !errors.Is(writeErr, stdio.ErrChanged) {
break
result, newRev, writeErr = settleSend(store, curReq, curRev, d)
if newRev != 0 {
break // this write landed
}
if attempt == 1 {
break // retried once already; give up
}
if writeErr != nil && !errors.Is(writeErr, stdio.ErrChanged) {
break // a hard failure, not a revision race: retrying blindly would not help
}
fresh, freshRev, ok, rerr := getSend(store, req.ID)
if rerr != nil || !ok {
@@ -909,8 +925,12 @@ func recordOutcome(store SendStore, emit Emit, req SendRequest, claimed SendDeci
}
curReq, curRev = fresh, freshRev
}
if writeErr != nil {
return d, false, errOr(indexErr, fmt.Errorf("the send %s's outcome could not be kept, even retried on a changed revision (%w)", req.ID, writeErr))
if newRev == 0 {
reportErr := writeErr
if reportErr == nil {
reportErr = fmt.Errorf("the send %s's outcome could not be kept: another writer's decision already stands, even retried on its fresh revision", req.ID)
}
return d, false, errOr(indexErr, reportErr)
}
if final {
// Only once the outcome is durably recorded is the sender-pending index's entry dropped: from