atagen

macltok

public
Star0
Watch0

Commit 417da9e719bc

tarball

atagen <boss@atagen.co>2026-10-26 03:11 UTC

7 files changed+177-45
repair: open a retained room from the store and probe only its newest gap

When a room already had retained events, the walk from the live end met the newest of them on its first page and fetched nothing. The real gap there was put in by the limited sync, and the room's sliding sync subscription catches up a stale room. So only an empty room walks now, and a retained one gets one page on its newest gap, which joins a gap that skipped nothing now that the fork (ec026bc9) joins on any event below it. A fill that runs out of pages leaves its gap Paused { reason: Budget } rather than Idle, so pressing fill visibly did something.
Modified file: Cargo.lock+10 additions−10 deletions
@@ -2217,7 +2217,7 @@ dependencies = [
22172217 [[package]]
22182218 name = "matrix-sdk"
22192219 version = "0.18.0"
2220-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2220+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
22212221 dependencies = [
22222222 "anymap2",
22232223 "aquamarine",
@@ -2280,7 +2280,7 @@ dependencies = [
22802280 [[package]]
22812281 name = "matrix-sdk-base"
22822282 version = "0.18.0"
2283-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2283+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
22842284 dependencies = [
22852285 "as_variant",
22862286 "assert_matches",
@@ -2310,7 +2310,7 @@ dependencies = [
23102310 [[package]]
23112311 name = "matrix-sdk-common"
23122312 version = "0.18.0"
2313-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2313+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
23142314 dependencies = [
23152315 "eyeball-im",
23162316 "futures-core",
@@ -2333,7 +2333,7 @@ dependencies = [
23332333 [[package]]
23342334 name = "matrix-sdk-crypto"
23352335 version = "0.18.0"
2336-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2336+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
23372337 dependencies = [
23382338 "aes",
23392339 "aquamarine",
@@ -2374,7 +2374,7 @@ dependencies = [
23742374 [[package]]
23752375 name = "matrix-sdk-indexeddb"
23762376 version = "0.18.0"
2377-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2377+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
23782378 dependencies = [
23792379 "async-trait",
23802380 "base64",
@@ -2406,7 +2406,7 @@ dependencies = [
24062406 [[package]]
24072407 name = "matrix-sdk-sqlite"
24082408 version = "0.18.0"
2409-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2409+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
24102410 dependencies = [
24112411 "as_variant",
24122412 "async-trait",
@@ -2433,7 +2433,7 @@ dependencies = [
24332433 [[package]]
24342434 name = "matrix-sdk-store-encryption"
24352435 version = "0.18.0"
2436-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2436+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
24372437 dependencies = [
24382438 "base64",
24392439 "blake3",
@@ -2454,7 +2454,7 @@ dependencies = [
24542454 [[package]]
24552455 name = "matrix-sdk-test"
24562456 version = "0.18.0"
2457-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2457+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
24582458 dependencies = [
24592459 "as_variant",
24602460 "ctor",
@@ -2477,7 +2477,7 @@ dependencies = [
24772477 [[package]]
24782478 name = "matrix-sdk-test-macros"
24792479 version = "0.18.0"
2480-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2480+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
24812481 dependencies = [
24822482 "quote",
24832483 "syn 2.0.119",
@@ -2486,7 +2486,7 @@ dependencies = [
24862486 [[package]]
24872487 name = "matrix-sdk-ui"
24882488 version = "0.18.0"
2489-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd#5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd"
2489+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=ec026bc9ea2393fd0cce6212b950d91ab6d36097#ec026bc9ea2393fd0cce6212b950d91ab6d36097"
24902490 dependencies = [
24912491 "as_variant",
24922492 "async-rx",
Modified file: Cargo.toml+9 additions−9 deletions
@@ -26,11 +26,11 @@ indexeddb = [
2626 futures-util = { version = "0.3.31", default-features = false, features = ["std"] }
2727 http = "1"
2828 imbl = { version = "6.1.0", default-features = false }
29-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["e2e-encryption"] }
30-matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false }
31-matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false }
32-matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", optional = true }
33-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false }
29+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false, features = ["e2e-encryption"] }
30+matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false }
31+matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false }
32+matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", optional = true }
33+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false }
3434 ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }
3535 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
3636 serde_json = { version = "1.0.143", features = ["raw_value"] }
@@ -54,8 +54,8 @@ js-sys = { version = "0.3.105", optional = true }
5454 # tantivy 0.26's merger reads Instant::now() whenever debug logging is on, and that panics in
5555 # wasm. both caps, since a release_max_level a host sets would otherwise win in release
5656 log = { version = "0.4.29", features = ["max_level_info", "release_max_level_info"] }
57-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["js"] }
58-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["js"] }
57+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false, features = ["js"] }
58+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false, features = ["js"] }
5959 # the sdk's own wasm reqwest leaves out stream, which the bounded media download needs
6060 reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }
6161 uuid = { version = "1", features = ["js"] }
@@ -70,8 +70,8 @@ web-sys = { version = "0.3.105", optional = true, features = [
7070 ] }
7171
7272 [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
73-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd", default-features = false, features = ["testing"] }
74-matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "5ec7600f0a5f648c6e2a34b5be0a1494a4e2eafd" }
73+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097", default-features = false, features = ["testing"] }
74+matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "ec026bc9ea2393fd0cce6212b950d91ab6d36097" }
7575 tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
7676 wiremock = "0.6.5"
7777
Modified file: src/engine/repair.rs+56 additions−12 deletions
@@ -103,8 +103,29 @@ impl RepairController {
103103 self.running.is_some()
104104 }
105105
106+ /// a room with retained events already shows the live end, and a limited sync has put its
107+ /// own gap there, so only an empty room walks back from the live end. otherwise the newest
108+ /// gap gets one page, so a limited sync that skipped nothing leaves no gap behind
106109 pub(super) async fn start_recent(&mut self) {
107- self.start_recent_with(None).await;
110+ let retained = async {
111+ let (cache, _handles) = self.room.event_cache().await?;
112+ if !cache.has_retained_events().await? {
113+ return Ok(None);
114+ }
115+ let gaps = cache.history_gaps().await?;
116+ Ok::<_, EventCacheError>(Some(gaps.into_iter().rev().find(|gap| {
117+ matches!(
118+ gap.status,
119+ HistoryGapStatus::Idle | HistoryGapStatus::Failed { .. }
120+ )
121+ })))
122+ }
123+ .await;
124+ match retained {
125+ Ok(Some(Some(gap))) => self.start_gap_fill(gap.id, None, 1, false).await,
126+ Ok(Some(None)) => {}
127+ Ok(None) | Err(_) => self.start_recent_with(None).await,
128+ }
108129 }
109130
110131 pub(super) async fn execute(&mut self, command: HistoryRepairCommand, reply: Responder) {
@@ -122,7 +143,8 @@ impl RepairController {
122143 }
123144 HistoryRepairCommand::RetryRecent { .. } => self.start_recent_with(Some(reply)).await,
124145 HistoryRepairCommand::FillGap { gap_id, .. } => {
125- self.start_gap_fill(gap_id, reply).await;
146+ self.start_gap_fill(gap_id, Some(reply), MAX_GAP_PAGES, true)
147+ .await;
126148 }
127149 }
128150 }
@@ -169,9 +191,17 @@ impl RepairController {
169191 });
170192 }
171193
172- async fn start_gap_fill(&mut self, gap_id: OwnedTransactionId, responder: Responder) {
194+ async fn start_gap_fill(
195+ &mut self,
196+ gap_id: OwnedTransactionId,
197+ responder: Option<Responder>,
198+ pages: u32,
199+ spend_budget: bool,
200+ ) {
173201 if self.is_running() {
174- responder.done(Err(room_busy()));
202+ if let Some(responder) = responder {
203+ responder.done(Err(room_busy()));
204+ }
175205 return;
176206 }
177207 let room = self.room.clone();
@@ -183,18 +213,24 @@ impl RepairController {
183213 .await
184214 .is_err()
185215 {
186- responder.done(Err(Error::interrupted()));
216+ if let Some(responder) = responder {
217+ responder.done(Err(Error::interrupted()));
218+ }
187219 return;
188220 }
189221 let (cancel, receiver) = watch::channel(false);
190- let task = spawn(async move { fill_gap(room, progress, receiver).await });
222+ let task =
223+ spawn(async move { fill_gap(room, progress, receiver, pages, spend_budget).await });
191224 self.running = Some(Running {
192225 task,
193226 cancel,
194- responders: vec![PendingResponder {
195- responder,
196- cancel_ack: false,
197- }],
227+ responders: responder
228+ .map(|responder| PendingResponder {
229+ responder,
230+ cancel_ack: false,
231+ })
232+ .into_iter()
233+ .collect(),
198234 gap_id: Some(gap_id),
199235 closing: false,
200236 });
@@ -285,11 +321,13 @@ async fn fill_gap(
285321 room: Room,
286322 mut report: HistoryRepairReport,
287323 mut cancel: watch::Receiver<bool>,
324+ pages: u32,
325+ spend_budget: bool,
288326 ) -> Finished {
289327 let mut committed = false;
290328 let stopped = async {
291329 let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
292- for _ in 0..MAX_GAP_PAGES {
330+ for _ in 0..pages {
293331 let next = tokio::select! {
294332 biased;
295333 _ = cancel.changed() => return Ok(Stopped::Cancelled),
@@ -339,10 +377,16 @@ async fn fill_gap(
339377 HistoryGapStatus::Failed { message } => {
340378 return Err(Error::new(ErrorKind::Network, message));
341379 }
342- HistoryGapStatus::Idle => {}
380+ HistoryGapStatus::Idle | HistoryGapStatus::BudgetSpent => {}
343381 }
344382 }
345383 report.stop = Some(HistoryRepairStop::Budget);
384+ if spend_budget {
385+ if let Err(error) = cache.spend_history_budget(report.gap_id.clone()).await {
386+ tracing::debug!(%error, "marking a history gap's spent budget failed");
387+ }
388+ committed = true;
389+ }
346390 Ok(Stopped::Done)
347391 }
348392 .await
Modified file: src/lib.rs+2 additions−2 deletions
@@ -77,8 +77,8 @@ pub use model::{
7777 VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
7878 };
7979 pub use repair_model::{
80- HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairCommand, HistoryRepairPhase,
81- HistoryRepairReport, HistoryRepairStop,
80+ HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryGapPause, HistoryRepairCommand,
81+ HistoryRepairPhase, HistoryRepairReport, HistoryRepairStop,
8282 };
8383 pub use ruma;
8484 pub use search_model::{
Modified file: src/normalise/timeline.rs+18 additions−5 deletions
@@ -19,9 +19,10 @@ use ruma::{
1919 };
2020
2121 use crate::{
22- Change, HistoryGap, HistoryGapLifecycle, MediaId, MembershipChange, Message, MessageKind,
23- MessageMentions, Reaction, ReplyDetails, ReplyReference, SendState, Sender, ThreadSummary,
24- TimelineContent, TimelineDiff, TimelineEvent, TimelineItem, TimelineItemId, UtdCause,
22+ Change, HistoryGap, HistoryGapLifecycle, HistoryGapPause, MediaId, MembershipChange, Message,
23+ MessageKind, MessageMentions, Reaction, ReplyDetails, ReplyReference, SendState, Sender,
24+ ThreadSummary, TimelineContent, TimelineDiff, TimelineEvent, TimelineItem, TimelineItemId,
25+ UtdCause,
2526 };
2627
2728 use super::link_preview::sender_previews;
@@ -75,7 +76,18 @@ impl Normaliser<'_> {
7576 HistoryGapStatus::Idle | HistoryGapStatus::Joined => {
7677 (HistoryGapLifecycle::Idle, None)
7778 }
78- HistoryGapStatus::Paused => (HistoryGapLifecycle::Paused, None),
79+ HistoryGapStatus::Paused => (
80+ HistoryGapLifecycle::Paused {
81+ reason: HistoryGapPause::CursorStalled,
82+ },
83+ None,
84+ ),
85+ HistoryGapStatus::BudgetSpent => (
86+ HistoryGapLifecycle::Paused {
87+ reason: HistoryGapPause::Budget,
88+ },
89+ None,
90+ ),
7991 HistoryGapStatus::Failed { message } => {
8092 (HistoryGapLifecycle::Failed, Some(message.clone()))
8193 }
@@ -107,7 +119,8 @@ impl Normaliser<'_> {
107119 timestamp_ms: embedded.timestamp.get().into(),
108120 },
109121 TimelineDetails::Unavailable => {
110- self.missing_replies.extend(event.event_id().map(ToOwned::to_owned));
122+ self.missing_replies
123+ .extend(event.event_id().map(ToOwned::to_owned));
111124 ReplyDetails::Pending
112125 }
113126 TimelineDetails::Pending => ReplyDetails::Pending,
Modified file: src/repair_model.rs+11 additions−1 deletions
@@ -34,12 +34,22 @@ pub struct HistoryGap {
3434 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
3535 pub enum HistoryGapLifecycle {
3636 Idle,
37- Paused,
37+ /// the last fill stopped short of joining, and another one carries on from there
38+ Paused {
39+ reason: HistoryGapPause,
40+ },
3841 Failed,
3942 /// accessible history ends before the older section, so this interval stays
4043 Unavailable,
4144 }
4245
46+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48+pub enum HistoryGapPause {
49+ CursorStalled,
50+ Budget,
51+}
52+
4353 #[derive(Clone, Debug, PartialEq, Eq)]
4454 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
4555 pub enum HistoryContinuity {
Modified file: tests/history_repair.rs+71 additions−6 deletions
@@ -181,7 +181,7 @@ fn gap_in(items: &imbl::Vector<TimelineItem>) -> Option<HistoryGap> {
181181 }
182182
183183 #[tokio::test]
184-async fn automatic_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap() {
184+async fn a_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap() {
185185 let room_id = room_id!("!recent-repair:localhost");
186186 let old_id = event_id!("$old:localhost");
187187 let new_ids = [
@@ -216,6 +216,10 @@ async fn automatic_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap(
216216 }
217217 cache_event_before_open(&room, room_id, old_id, "retained old event").await;
218218 open_room(&mut room, room_id).await;
219+ room.handle
220+ .retry_recent_history(room_id.to_owned())
221+ .await
222+ .unwrap();
219223 timeline_until(&mut room, |items| {
220224 gap_in(items).is_some()
221225 && has_event(items, old_id)
@@ -342,6 +346,10 @@ async fn fill_gap_returns_after_the_mirror_contains_the_committed_rows() {
342346 )
343347 .await;
344348 handle.open_room(room_id.to_owned()).await.unwrap();
349+ handle
350+ .retry_recent_history(room_id.to_owned())
351+ .await
352+ .unwrap();
345353 let timeline_id = TimelineId::from(room_id.to_owned());
346354 let gap = tokio::time::timeout(Duration::from_secs(5), async {
347355 loop {
@@ -452,6 +460,10 @@ async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
452460 .await;
453461 cache_event_before_open(&room, room_id, old_id, "old cached").await;
454462 open_room(&mut room, room_id).await;
463+ room.handle
464+ .retry_recent_history(room_id.to_owned())
465+ .await
466+ .unwrap();
455467 timeline_until(&mut room, |items| gap_in(items).is_some()).await;
456468 let gap = gap_in(&room.items).unwrap();
457469 assert_eq!(
@@ -548,7 +560,7 @@ async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
548560 }
549561
550562 #[tokio::test]
551-async fn failed_automatic_recent_load_is_public_and_keeps_the_cached_timeline() {
563+async fn failed_recent_load_is_public_and_keeps_the_cached_timeline() {
552564 let room_id = room_id!("!failed-recent:localhost");
553565 let old_id = event_id!("$failed-old:localhost");
554566 let mut room = start_room(room_id).await;
@@ -563,6 +575,12 @@ async fn failed_automatic_recent_load_is_public_and_keeps_the_cached_timeline()
563575 .await;
564576 cache_event_before_open(&room, room_id, old_id, "kept event").await;
565577 open_room(&mut room, room_id).await;
578+ assert!(
579+ room.handle
580+ .retry_recent_history(room_id.to_owned())
581+ .await
582+ .is_err()
583+ );
566584 let state = wait_for_recent(&mut room, room_id).await;
567585 assert!(matches!(state, RecentHistoryState::Failed(_)));
568586 shut_down(&room.handle, &mut room.events, room.running).await;
@@ -653,12 +671,59 @@ async fn a_reply_whose_target_is_not_loaded_fetches_it() {
653671 timeline_until(&mut room, |items| {
654672 items.iter().any(|item| {
655673 matches!(item, TimelineItem::Event(event)
656- if event.event_id.as_deref() == Some(reply_id)
657- && event.reply_to.as_ref().is_some_and(|reply| {
658- matches!(reply.details, macltok::ReplyDetails::Ready { .. })
659- }))
674+ if event.event_id.as_deref() == Some(reply_id)
675+ && event.reply_to.as_ref().is_some_and(|reply| {
676+ matches!(reply.details, macltok::ReplyDetails::Ready { .. })
677+ }))
660678 })
661679 })
662680 .await;
663681 shut_down(&room.handle, &mut room.events, room.running).await;
664682 }
683+
684+#[tokio::test]
685+async fn opening_a_retained_room_only_probes_its_newest_gap() {
686+ let room_id = room_id!("!probe:localhost");
687+ let old_id = event_id!("$probe-old:localhost");
688+ let live_id = event_id!("$probe-live:localhost");
689+ let mut room = start_room(room_id).await;
690+ cache_event_before_open(&room, room_id, old_id, "retained").await;
691+ room.server
692+ .sync_room(
693+ &room.sync_client,
694+ JoinedRoomBuilder::new(room_id)
695+ .add_timeline_event(
696+ EventFactory::new()
697+ .text_msg("live")
698+ .event_id(live_id)
699+ .sender(&ALICE),
700+ )
701+ .set_timeline_limited()
702+ .set_timeline_prev_batch("sync-edge"),
703+ )
704+ .await;
705+ room.server
706+ .mock_room_messages()
707+ .match_from("sync-edge")
708+ .ok(RoomMessagesResponseTemplate::default()
709+ .end_token("further")
710+ .events(vec![history_event(room_id, old_id, "retained")]))
711+ .mock_once()
712+ .mount()
713+ .await;
714+ open_room(&mut room, room_id).await;
715+ let report = wait_for_settled_fill(&mut room).await;
716+ assert_eq!(report.pages, 1);
717+ assert_eq!(report.stop, None);
718+ timeline_until(&mut room, |items| {
719+ gap_in(items).is_none() && has_event(items, old_id) && has_event(items, live_id)
720+ })
721+ .await;
722+ let requests = room.server.received_requests().await.unwrap();
723+ let messages = requests
724+ .iter()
725+ .filter(|request| request.url.path().ends_with("/messages"))
726+ .count();
727+ assert_eq!(messages, 1);
728+ shut_down(&room.handle, &mut room.events, room.running).await;
729+}