fix(protocol): a notice from an older generation or a left epoch is ignored (improvise-z2vr)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Claude (dbsp-delivery)
2026-10-10 19:35:49 -07:00
committed by Edward Langley
parent ec5112cfe8
commit f47efef5ae
4 changed files with 99 additions and 5 deletions
+7
View File
@@ -127,6 +127,13 @@ for the reply to a request they just sent, takes an epoch off `left` before it a
reply that arrives on its own, and keeps the guard. A stale reply can still move the client for one round;
the next request it makes is answered by the live host and brings it back.
**A notice from an older generation of the host the client is on, or from an epoch it has left, is
ignored.** Reading one rewinds `seen` into the old epoch and queues a correction entry, and the
correction that answers it is then covered by the watermark and a duplicate, so a `restructured` mark
set from the same notice is never cleared and `sync` returns `Exhausted`. A notice the client asked for
(`poll`) skips the left-epoch test, since the host that answers is live (see the answer rule above); it
still skips an older generation. When every shape ignores a notice, its events are dropped too.
`poll` hands a pulled notice only to the shape it asked about. A notice carries only that shape's keys,
and applying it to another tracked shape would advance that shape past keys it never saw.
+14
View File
@@ -192,3 +192,17 @@ test is the pin. Rival: the left-epoch guard removed fails
The property's invariant changed with it. "The watermark never returns to an epoch it left" was false by
design once an unknown-epoch reply can move the client, so the property now checks the narrower, true
rule: a replayed reply from an epoch the client was in and has left must be a duplicate.
## The old-notice livelock
### a_late_notice_from_an_older_generation_cannot_rewind_the_client
The property failed with `Exhausted` on a shrunk history (two swaps, a transpose, a limit change, the first
swap's notice delivered three steps late, an old reply replayed). The client had settled on generation 2 when
the generation-1 notice arrived: `seen` was rewound into generation 1, a notice from the same history set
`restructured`, and the window correction that answered was covered by the watermark, so it never cleared
the mark and `poll` retried until `Exhausted`. The test replays the history step for step, with the
property's own weather and a Flaky edge. Rival: the notice-ignore test in `accept` disabled; this test
and the random property fail. The seed file is not committed (`proptest-regressions/` is ignored).
Run after the fix, the property passed 4 x 4000 cases and 2 x 20000 cases, with no third failure.
@@ -1337,6 +1337,66 @@ mod random {
Ok(())
}
/// The shrunk history of the second livelock: two swaps, a transpose and a limit change, with
/// the first swap's notice delivered three steps late and an old reply replayed at the end.
#[test]
fn a_late_notice_from_an_older_generation_cannot_rewind_the_client() {
let window = Window {
top: 0,
left: 0,
rows: 1,
cols: 1,
};
let mut host = host_over(workbook(), 1);
let id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), window);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
host.take_notices();
let mut held_back: Vec<(usize, Notice)> = Vec::new();
let mut old: Vec<Reply> = Vec::new();
let plan = [
(Some(0), Arrival::Late(3), vec![Some(0)], None),
(Some(0), Arrival::Dropped, vec![], None),
(Some(1), Arrival::Now, vec![], None),
(None, Arrival::Dropped, vec![], Some(6)),
];
for (step, (op, arrival, sync, replay)) in plan.into_iter().enumerate() {
match op {
Some(0) => {
send(
&mut host,
id,
vec![ModelCommand::LoadModel("other.improv".into())],
);
}
Some(_) => {
send(&mut host, id, vec![transpose()]);
}
None => host.limit_delivery(2, 1),
}
let w = Weather {
arrival,
lose_connection: true,
sync: sync.clone(),
replay,
};
let fresh = host.take_notices();
arrive(&mut client, &mut held_back, step, &w, fresh);
client.connection_lost();
if let Some(Reply::Correction(c)) = replay.and_then(|i| old.get(i % old.len().max(1))) {
client.apply(c);
}
let script = sync.iter().map(|f| f.map(fault)).collect();
let mut edge = Flaky::scripted(&mut host, script);
client.sync(&mut edge, &shape(), 6);
old.append(&mut edge.replies);
}
let (state, _) = client.poll(&mut Flaky::new(&mut host), &shape());
assert_eq!(state, Synced::Settled, "an old notice must not queue work");
assert_eq!(client.held(&shape()), Some(&drawn(&host, &window)));
}
proptest! {
#[test]
fn a_corrected_state_is_the_target_and_correcting_it_again_is_the_zero_zset(
@@ -162,16 +162,26 @@ impl EdgeClient {
/// A pushed notice. Duplicates are ignored, a gap or a new epoch makes every shape ask.
pub fn on_notice(&mut self, notice: &Notice) -> Vec<Event> {
self.accept(notice, None)
self.accept(notice, None, false)
}
/// `only` limits the notice to one shape: a pulled notice carries no other shape's keys.
fn accept(&mut self, notice: &Notice, only: Option<&ShapeKey>) -> Vec<Event> {
fn accept(&mut self, notice: &Notice, only: Option<&ShapeKey>, answer: bool) -> Vec<Event> {
let end = notice.stamp();
let (mut read, mut ignored) = (0, 0);
for (shape, t) in &mut self.shapes {
if only.is_some_and(|o| o != shape) {
continue;
}
read += 1;
let older = |s: Option<Stamp>| {
s.is_some_and(|s| s.host == end.host && end.generation < s.generation)
};
let left = !answer && t.left.contains(&(end.host, end.generation));
if older(t.watermark) || older(t.seen) || left {
ignored += 1;
continue;
}
let contiguous = match t.seen {
Some(seen) if seen.same_epoch(&end) => match notice.range.to <= seen.publication {
true => continue,
@@ -197,7 +207,10 @@ impl EdgeClient {
t.watermark = Some(end);
}
}
self.events_of(notice)
match read > 0 && ignored == read {
true => Vec::new(),
false => self.events_of(notice),
}
}
/// The connection dropped: every shape asks for a correction from its watermark.
@@ -365,7 +378,7 @@ impl EdgeClient {
Ok(Reply::Correction(c)) => events.extend(self.apply_answer(&c).1),
Ok(Reply::Notices(list)) => {
list.iter()
.for_each(|n| events.extend(self.accept(n, Some(shape))));
.for_each(|n| events.extend(self.accept(n, Some(shape), true)));
}
Err(EdgeError::Failed) => {
self.request_failed(shape);
@@ -401,7 +414,7 @@ impl EdgeClient {
match edge.call(request) {
Ok(Reply::Notices(list)) => list
.iter()
.for_each(|n| events.extend(self.accept(n, Some(shape)))),
.for_each(|n| events.extend(self.accept(n, Some(shape), true))),
Ok(Reply::Correction(c)) => events.extend(self.apply_answer(&c).1),
Err(EdgeError::Failed) => {
self.request_failed(shape);