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
@@ -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);