atagen

macltok

public
Star0
Watch0

Commit 80381e06a573

tarball

atagen <boss@atagen.co>2026-10-25 03:22 UTC

15 files changed+2215-66
timeline: expose bounded room history repair
Modified file: Cargo.lock+10 additions−10 deletions
@@ -2002,7 +2002,7 @@ dependencies = [
20022002 [[package]]
20032003 name = "matrix-sdk"
20042004 version = "0.18.0"
2005-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2005+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
20062006 dependencies = [
20072007 "anymap2",
20082008 "aquamarine",
@@ -2065,7 +2065,7 @@ dependencies = [
20652065 [[package]]
20662066 name = "matrix-sdk-base"
20672067 version = "0.18.0"
2068-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2068+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
20692069 dependencies = [
20702070 "as_variant",
20712071 "assert_matches",
@@ -2095,7 +2095,7 @@ dependencies = [
20952095 [[package]]
20962096 name = "matrix-sdk-common"
20972097 version = "0.18.0"
2098-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2098+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
20992099 dependencies = [
21002100 "eyeball-im",
21012101 "futures-core",
@@ -2118,7 +2118,7 @@ dependencies = [
21182118 [[package]]
21192119 name = "matrix-sdk-crypto"
21202120 version = "0.18.0"
2121-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2121+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
21222122 dependencies = [
21232123 "aes",
21242124 "aquamarine",
@@ -2159,7 +2159,7 @@ dependencies = [
21592159 [[package]]
21602160 name = "matrix-sdk-indexeddb"
21612161 version = "0.18.0"
2162-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2162+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
21632163 dependencies = [
21642164 "async-trait",
21652165 "base64",
@@ -2191,7 +2191,7 @@ dependencies = [
21912191 [[package]]
21922192 name = "matrix-sdk-sqlite"
21932193 version = "0.18.0"
2194-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2194+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
21952195 dependencies = [
21962196 "as_variant",
21972197 "async-trait",
@@ -2218,7 +2218,7 @@ dependencies = [
22182218 [[package]]
22192219 name = "matrix-sdk-store-encryption"
22202220 version = "0.18.0"
2221-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2221+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
22222222 dependencies = [
22232223 "base64",
22242224 "blake3",
@@ -2239,7 +2239,7 @@ dependencies = [
22392239 [[package]]
22402240 name = "matrix-sdk-test"
22412241 version = "0.18.0"
2242-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2242+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
22432243 dependencies = [
22442244 "as_variant",
22452245 "ctor",
@@ -2262,7 +2262,7 @@ dependencies = [
22622262 [[package]]
22632263 name = "matrix-sdk-test-macros"
22642264 version = "0.18.0"
2265-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2265+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
22662266 dependencies = [
22672267 "quote",
22682268 "syn 2.0.119",
@@ -2271,7 +2271,7 @@ dependencies = [
22712271 [[package]]
22722272 name = "matrix-sdk-ui"
22732273 version = "0.18.0"
2274-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2274+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
22752275 dependencies = [
22762276 "as_variant",
22772277 "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 = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false, features = ["e2e-encryption"] }
30-matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false }
31-matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false }
32-matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", optional = true }
33-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false }
29+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["e2e-encryption"] }
30+matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
31+matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
32+matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", optional = true }
33+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", 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 = "1.0.143"
@@ -48,8 +48,8 @@ getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
4848 getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
4949 indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }
5050 js-sys = { version = "0.3.105", optional = true }
51-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false, features = ["js"] }
52-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false, features = ["js"] }
51+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["js"] }
52+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["js"] }
5353 # the sdk's own wasm reqwest leaves out stream, which the bounded media download needs
5454 reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }
5555 uuid = { version = "1", features = ["js"] }
@@ -64,8 +64,8 @@ web-sys = { version = "0.3.105", optional = true, features = [
6464 ] }
6565
6666 [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
67-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48", default-features = false, features = ["testing"] }
68-matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "7aace883fbe03d4c3c118b55188fe9519ba8ce48" }
67+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["testing"] }
68+matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5" }
6969 tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
7070 wiremock = "0.6.5"
7171
Modified file: src/engine/mod.rs+53 additions
@@ -5,6 +5,7 @@ mod media;
55 mod mutations;
66 mod packs;
77 mod prepare;
8+mod repair;
89 mod room;
910 mod session;
1011 mod timeline;
@@ -325,6 +326,8 @@ pub struct Engine {
325326 mentions: crate::mentions::service::Handle,
326327 mentions_service: Option<crate::mentions::service::Service>,
327328 mentions_updates: Option<watch::Receiver<crate::MentionsPage>>,
329+ repair_gate: Arc<tokio::sync::Mutex<()>>,
330+ repair_shutdown: Arc<std::sync::atomic::AtomicBool>,
328331 }
329332
330333 impl Engine {
@@ -434,6 +437,8 @@ impl Engine {
434437 mentions,
435438 mentions_service: Some(mentions_service),
436439 mentions_updates: Some(mentions_updates),
440+ repair_gate: Arc::default(),
441+ repair_shutdown: Arc::default(),
437442 };
438443 Ok((engine, handle, events))
439444 }
@@ -838,6 +843,7 @@ impl Engine {
838843 reply.send(mentions.execute(command).await);
839844 });
840845 }
846+ Command::HistoryRepair(command) => self.route_repair(command, reply),
841847 Command::Media(MediaCommand::Upload(upload)) => self.upload(upload, reply),
842848 Command::Encryption(command) => enqueue(&self.encryption, Job { command, reply }),
843849 Command::Notification(command) => {
@@ -976,6 +982,44 @@ impl Engine {
976982 }
977983 }
978984
985+ fn route_repair(&mut self, command: crate::HistoryRepairCommand, reply: Responder) {
986+ use crate::HistoryRepairCommand;
987+ let room_id = match &command {
988+ HistoryRepairCommand::Start { room_id, .. }
989+ | HistoryRepairCommand::Continue { room_id, .. }
990+ | HistoryRepairCommand::Cancel { room_id }
991+ | HistoryRepairCommand::StartRecent { room_id } => room_id,
992+ HistoryRepairCommand::GetPolicy | HistoryRepairCommand::SetPolicy(_) => {
993+ let mentions = self.mentions.clone();
994+ self.tasks
995+ .spawn(TaskContext::Command("history repair policy"), async move {
996+ let result = match command {
997+ HistoryRepairCommand::GetPolicy => mentions
998+ .repair_policy()
999+ .await
1000+ .map(crate::CommandOutput::HistoryRepairPolicy),
1001+ HistoryRepairCommand::SetPolicy(policy) => mentions
1002+ .set_repair_policy(policy)
1003+ .await
1004+ .map(|_| crate::CommandOutput::Done),
1005+ _ => unreachable!(),
1006+ };
1007+ reply.send(result);
1008+ });
1009+ return;
1010+ }
1011+ };
1012+ match self.rooms.get(room_id) {
1013+ Some(RoomSlot::Active { sender, .. }) => {
1014+ send_actor(sender, ActorRequest::Repair(command, reply))
1015+ }
1016+ Some(RoomSlot::Closing { .. }) => {
1017+ reply.done(Err(Error::new(ErrorKind::RoomBusy, "room is closing")))
1018+ }
1019+ None => reply.done(Err(Error::new(ErrorKind::RoomNotOpen, "room is not open"))),
1020+ }
1021+ }
1022+
9791023 async fn open_room(&mut self, room_id: OwnedRoomId, reply: Responder) {
9801024 match self.rooms.get_mut(&room_id) {
9811025 Some(RoomSlot::Active { sender, .. }) => send_actor(sender, ActorRequest::Open(reply)),
@@ -1039,6 +1083,10 @@ impl Engine {
10391083 current_user_id: self.client.user_id().map(ToOwned::to_owned),
10401084 initial_history_limit: self.config.initial_history_limit,
10411085 supervisor,
1086+ mentions: self.mentions.clone(),
1087+ sync: self.sync.clone(),
1088+ repair_gate: self.repair_gate.clone(),
1089+ repair_shutdown: self.repair_shutdown.clone(),
10421090 },
10431091 panics,
10441092 opening: vec![reply],
@@ -1094,6 +1142,7 @@ impl Engine {
10941142 ActorRequest::Open(reply) => self.open_room(room_id.clone(), reply).await,
10951143 ActorRequest::Close(reply) => reply.done(Ok(())),
10961144 ActorRequest::Execute(command, reply) => self.route_timeline(command, reply),
1145+ ActorRequest::Repair(command, reply) => self.route_repair(command, reply),
10971146 }
10981147 }
10991148 for deferred in pending {
@@ -1116,11 +1165,15 @@ impl Engine {
11161165 }
11171166
11181167 async fn shutdown(&mut self) {
1168+ self.repair_shutdown
1169+ .store(true, std::sync::atomic::Ordering::Release);
11191170 if let Some(handler) = self.verification_handler.take() {
11201171 self.client.remove_event_handler(handler);
11211172 }
11221173 self.port.close();
1174+ let repair_gate = self.repair_gate.lock().await;
11231175 self.sync.stop().await;
1176+ drop(repair_gate);
11241177 if self.mentions_service.is_none() {
11251178 self.mentions.stop().await;
11261179 }
Added file: src/engine/repair.rs+608 additions
@@ -0,0 +1,608 @@
1+use std::{
2+ collections::HashSet,
3+ sync::{
4+ Arc,
5+ atomic::{AtomicBool, Ordering},
6+ },
7+ time::Duration,
8+};
9+
10+use matrix_sdk::{Room, deserialized_responses::TimelineEvent, room::MessagesOptions};
11+use matrix_sdk_ui::sync_service::SyncService;
12+use ruma::{OwnedEventId, time::Instant};
13+use tokio::sync::{OwnedMutexGuard, mpsc, oneshot, watch};
14+
15+use super::{
16+ executor::{JoinHandle, spawn},
17+ timeline::SessionContext,
18+};
19+use crate::{
20+ CommandOutput, Error, ErrorKind, Event, HistoryContinuity, HistoryRepairBudget,
21+ HistoryRepairCommand, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport,
22+ HistoryRepairStop, HistoryRepairStrategy, transport::Responder,
23+};
24+
25+const PAGE_SIZE: u16 = 50;
26+const STAGING_LIMIT: usize = 10_000;
27+
28+pub(super) struct InstallRequest {
29+ pub(super) events: Vec<TimelineEvent>,
30+ pub(super) prev_batch: Option<String>,
31+ pub(super) reply: oneshot::Sender<Result<(), Error>>,
32+}
33+
34+struct Stage {
35+ events: Vec<TimelineEvent>,
36+ cursor: Option<String>,
37+ anchor: Option<OwnedEventId>,
38+ report: HistoryRepairReport,
39+}
40+
41+struct Running {
42+ task: JoinHandle<Stage>,
43+ cancel: watch::Sender<bool>,
44+ reply: Responder,
45+}
46+
47+pub(super) struct RepairController {
48+ room: Room,
49+ context: SessionContext,
50+ installs: mpsc::Sender<InstallRequest>,
51+ requests: mpsc::Receiver<InstallRequest>,
52+ running: Option<Running>,
53+ stage: Option<Stage>,
54+}
55+
56+impl RepairController {
57+ pub(super) fn new(room: Room, context: SessionContext) -> Self {
58+ let (installs, requests) = mpsc::channel(1);
59+ Self {
60+ room,
61+ context,
62+ installs,
63+ requests,
64+ running: None,
65+ stage: None,
66+ }
67+ }
68+
69+ pub(super) fn is_running(&self) -> bool {
70+ self.running.is_some()
71+ }
72+
73+ pub(super) async fn execute(&mut self, command: HistoryRepairCommand, reply: Responder) {
74+ if matches!(command, HistoryRepairCommand::Cancel { .. }) {
75+ if let Some(running) = &self.running {
76+ running.cancel.send_replace(true);
77+ }
78+ reply.done(Ok(()));
79+ return;
80+ }
81+ if self.is_running() {
82+ reply.done(Err(Error::new(
83+ ErrorKind::RoomBusy,
84+ "room history repair is running",
85+ )));
86+ return;
87+ }
88+ let (stage, budget) = match command {
89+ HistoryRepairCommand::Start { policy, budget, .. } => {
90+ if let Err(error) = validate_budget(&budget) {
91+ reply.done(Err(error));
92+ return;
93+ }
94+ let policy = match policy {
95+ Some(policy) => policy,
96+ None => match self.context.mentions.repair_policy().await {
97+ Ok(policy) => policy,
98+ Err(error) => {
99+ reply.done(Err(error));
100+ return;
101+ }
102+ },
103+ };
104+ let coverage = match self
105+ .context
106+ .mentions
107+ .repair_coverage(self.room.room_id().to_owned())
108+ .await
109+ {
110+ Ok(coverage) => coverage,
111+ Err(error) => {
112+ reply.done(Err(error));
113+ return;
114+ }
115+ };
116+ let duration = coverage.duration_ms();
117+ let report = HistoryRepairReport {
118+ room_id: self.room.room_id().to_owned(),
119+ requested_policy: policy.clone(),
120+ strategy: HistoryRepairStrategy::Recent,
121+ coverage_duration_ms: duration,
122+ threshold_evaluated: duration.is_some()
123+ && coverage.unresolved
124+ && matches!(policy, HistoryRepairPolicy::BridgeShortGaps { .. }),
125+ pages: 0,
126+ events: 0,
127+ phase: HistoryRepairPhase::FetchingRecent,
128+ continuity: HistoryContinuity::Unresolved,
129+ recent_available: false,
130+ stop: None,
131+ failure: None,
132+ };
133+ (
134+ Stage {
135+ events: Vec::new(),
136+ cursor: None,
137+ anchor: None,
138+ report,
139+ },
140+ budget,
141+ )
142+ }
143+ HistoryRepairCommand::Continue { budget, .. } => {
144+ if let Err(error) = validate_budget(&budget) {
145+ reply.done(Err(error));
146+ return;
147+ }
148+ let Some(mut stage) = self.stage.take() else {
149+ reply.done(Err(Error::new(
150+ ErrorKind::Invalid,
151+ "no history bridge to continue",
152+ )));
153+ return;
154+ };
155+ if !stage.report.recent_available
156+ || stage.report.continuity != HistoryContinuity::Unresolved
157+ {
158+ reply.done(Err(Error::new(
159+ ErrorKind::Invalid,
160+ "history bridge is not resumable",
161+ )));
162+ self.stage = Some(stage);
163+ return;
164+ }
165+ stage.report.strategy = HistoryRepairStrategy::Bridge;
166+ stage.report.phase = HistoryRepairPhase::Bridging;
167+ stage.report.stop = None;
168+ stage.report.failure = None;
169+ (stage, budget)
170+ }
171+ HistoryRepairCommand::StartRecent { .. } => {
172+ let Some(mut stage) = self.stage.take() else {
173+ reply.done(Err(Error::new(
174+ ErrorKind::Invalid,
175+ "no recent history repair",
176+ )));
177+ return;
178+ };
179+ if !stage.report.recent_available {
180+ self.stage = Some(stage);
181+ reply.done(Err(Error::new(
182+ ErrorKind::Invalid,
183+ "recent history is not available, retry the repair",
184+ )));
185+ return;
186+ }
187+ stage.report.strategy = HistoryRepairStrategy::Recent;
188+ stage.report.phase = HistoryRepairPhase::Ready;
189+ stage.report.stop = None;
190+ stage.report.failure = None;
191+ let report = stage.report.clone();
192+ let published = self
193+ .context
194+ .events
195+ .publish_repair(report.clone())
196+ .await
197+ .map_err(|_| Error::interrupted());
198+ reply.send(published.map(|_| CommandOutput::HistoryRepair(report)));
199+ return;
200+ }
201+ _ => {
202+ reply.done(Err(Error::new(
203+ ErrorKind::Invalid,
204+ "repair command was misrouted",
205+ )));
206+ return;
207+ }
208+ };
209+ let (cancel, receiver) = watch::channel(false);
210+ let room = self.room.clone();
211+ let context = self.context.clone();
212+ let installs = self.installs.clone();
213+ let task =
214+ spawn(async move { run(room, context, installs, receiver, stage, budget).await });
215+ self.running = Some(Running {
216+ task,
217+ cancel,
218+ reply,
219+ });
220+ }
221+
222+ pub(super) async fn next_install(&mut self) -> Option<InstallRequest> {
223+ let running = self.running.as_mut()?;
224+ tokio::select! {
225+ request = self.requests.recv() => request,
226+ result = &mut running.task => {
227+ let running = self.running.take().expect("repair task exists");
228+ match result {
229+ Ok(stage) => {
230+ let report = stage.report.clone();
231+ let published = self.context.events.publish_repair(report.clone()).await
232+ .map_err(|_| Error::interrupted());
233+ running.reply.send(published.map(|_| CommandOutput::HistoryRepair(report)));
234+ self.stage = Some(stage);
235+ }
236+ Err(_) => running.reply.done(Err(Error::interrupted())),
237+ }
238+ None
239+ }
240+ }
241+ }
242+
243+ pub(super) async fn shutdown(&mut self) {
244+ if let Some(mut running) = self.running.take() {
245+ running.task.abort();
246+ match (&mut running.task).await {
247+ Ok(stage) => {
248+ let report = stage.report;
249+ let published = self
250+ .context
251+ .events
252+ .publish_repair(report.clone())
253+ .await
254+ .map_err(|_| Error::interrupted());
255+ running
256+ .reply
257+ .send(published.map(|_| CommandOutput::HistoryRepair(report)));
258+ }
259+ Err(_) => running.reply.done(Err(Error::interrupted())),
260+ }
261+ }
262+ }
263+}
264+
265+impl Drop for RepairController {
266+ fn drop(&mut self) {
267+ if let Some(running) = &self.running {
268+ running.task.abort();
269+ }
270+ }
271+}
272+
273+fn validate_budget(budget: &HistoryRepairBudget) -> Result<(), Error> {
274+ if !(1..=100).contains(&budget.max_pages)
275+ || !(50..=10_000).contains(&budget.max_events)
276+ || !(1..=60_000).contains(&budget.max_duration_ms)
277+ {
278+ return Err(Error::new(
279+ ErrorKind::Invalid,
280+ "repair budget requires 1 to 100 pages, 50 to 10000 events, and 1 to 60000 milliseconds",
281+ ));
282+ }
283+ Ok(())
284+}
285+
286+fn should_bridge(policy: &HistoryRepairPolicy, duration: Option<u64>, boundary: bool) -> bool {
287+ match policy {
288+ HistoryRepairPolicy::Bridge => true,
289+ HistoryRepairPolicy::StartRecent => false,
290+ HistoryRepairPolicy::BridgeShortGaps { threshold_ms } => {
291+ boundary
292+ && *threshold_ms > 0
293+ && duration.is_some_and(|duration| duration <= *threshold_ms)
294+ }
295+ }
296+}
297+
298+async fn run(
299+ room: Room,
300+ context: SessionContext,
301+ installs: mpsc::Sender<InstallRequest>,
302+ mut cancel: watch::Receiver<bool>,
303+ mut stage: Stage,
304+ budget: HistoryRepairBudget,
305+) -> Stage {
306+ let started = Instant::now();
307+ let initial_pages = stage.report.pages;
308+ let initial_events = stage.report.events;
309+ let result = async {
310+ if !stage.report.recent_available {
311+ let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
312+ stage.anchor = cache
313+ .events()
314+ .await
315+ .map_err(cache_error)?
316+ .iter()
317+ .find_map(TimelineEvent::event_id);
318+ let _ = context
319+ .events
320+ .send(Event::HistoryRepairChanged(stage.report.clone()))
321+ .await;
322+ let guard = pause_sync(&context, &mut cancel, &started, &budget).await?;
323+ let page = fetch(&room, None, &mut cancel, &started, &budget).await?;
324+ stage.report.pages += 1;
325+ stage.report.events += page.chunk.len() as u32;
326+ stage.cursor = page.end;
327+ stage.events = page.chunk.into_iter().rev().collect();
328+ deduplicate(&mut stage.events);
329+ install(&installs, &stage).await?;
330+ stage.report.recent_available = true;
331+ if contains_anchor(&stage) {
332+ stage.report.strategy = HistoryRepairStrategy::Overlap;
333+ stage.report.continuity = HistoryContinuity::Verified;
334+ } else if stage.cursor.is_none() {
335+ stage.report.continuity = HistoryContinuity::AccessibleStart;
336+ }
337+ if stage.report.continuity != HistoryContinuity::Unresolved {
338+ context
339+ .mentions
340+ .repair_resolved(room.room_id().to_owned())
341+ .await?;
342+ }
343+ guard.finish().await;
344+ let _ = context
345+ .events
346+ .send(Event::HistoryRepairChanged(stage.report.clone()))
347+ .await;
348+ if stage.report.continuity != HistoryContinuity::Unresolved {
349+ return Ok(());
350+ }
351+ if !should_bridge(
352+ &stage.report.requested_policy,
353+ stage.report.coverage_duration_ms,
354+ stage.report.threshold_evaluated,
355+ ) {
356+ return Ok(());
357+ }
358+ stage.report.strategy = HistoryRepairStrategy::Bridge;
359+ }
360+ stage.report.phase = HistoryRepairPhase::Bridging;
361+ let _ = context
362+ .events
363+ .send(Event::HistoryRepairChanged(stage.report.clone()))
364+ .await;
365+ if contains_anchor(&stage) {
366+ stage.report.continuity = HistoryContinuity::Verified;
367+ } else if stage.cursor.is_none() {
368+ stage.report.continuity = HistoryContinuity::AccessibleStart;
369+ }
370+ while stage.report.continuity == HistoryContinuity::Unresolved {
371+ if *cancel.borrow() {
372+ stage.report.stop = Some(HistoryRepairStop::Cancelled);
373+ break;
374+ }
375+ if stage.report.pages - initial_pages + 1 >= u32::from(budget.max_pages)
376+ || stage.report.events - initial_events + 2 * u32::from(PAGE_SIZE)
377+ > budget.max_events
378+ || stage.events.len() + 2 * usize::from(PAGE_SIZE) > STAGING_LIMIT
379+ || started.elapsed() >= Duration::from_millis(budget.max_duration_ms)
380+ {
381+ stage.report.stop = Some(HistoryRepairStop::Budget);
382+ break;
383+ }
384+ if stage.anchor.is_none() {
385+ stage.report.stop = Some(HistoryRepairStop::NoAnchor);
386+ break;
387+ }
388+ let Some(cursor) = stage.cursor.clone() else {
389+ break;
390+ };
391+ let page = fetch(&room, Some(&cursor), &mut cancel, &started, &budget).await?;
392+ stage.report.pages += 1;
393+ stage.report.events += page.chunk.len() as u32;
394+ if page.end.as_deref() == Some(&cursor) {
395+ stage.report.stop = Some(HistoryRepairStop::CursorStalled);
396+ break;
397+ }
398+ let mut older: Vec<_> = page.chunk.into_iter().rev().collect();
399+ older.append(&mut stage.events);
400+ deduplicate(&mut older);
401+ stage.events = older;
402+ stage.cursor = page.end;
403+ if contains_anchor(&stage) {
404+ stage.report.continuity = HistoryContinuity::Verified;
405+ } else if stage.cursor.is_none() {
406+ stage.report.continuity = HistoryContinuity::AccessibleStart;
407+ }
408+ if stage.report.continuity == HistoryContinuity::Unresolved {
409+ let _ = context
410+ .events
411+ .send(Event::HistoryRepairChanged(stage.report.clone()))
412+ .await;
413+ }
414+ }
415+ if stage.report.continuity != HistoryContinuity::Unresolved
416+ && stage.report.strategy == HistoryRepairStrategy::Bridge
417+ {
418+ let guard = pause_sync(&context, &mut cancel, &started, &budget).await?;
419+ let head = fetch(&room, None, &mut cancel, &started, &budget).await?;
420+ stage.report.pages += 1;
421+ stage.report.events += head.chunk.len() as u32;
422+ let head: Vec<_> = head.chunk.into_iter().rev().collect();
423+ if !extend_head(&mut stage.events, head) {
424+ stage.report.continuity = HistoryContinuity::Unresolved;
425+ stage.report.stop = Some(HistoryRepairStop::HistoryUnavailable);
426+ return Ok(());
427+ }
428+ install(&installs, &stage).await?;
429+ context
430+ .mentions
431+ .repair_resolved(room.room_id().to_owned())
432+ .await?;
433+ guard.finish().await;
434+ }
435+ Ok::<_, Error>(())
436+ }
437+ .await;
438+ if let Err(error) = result {
439+ if error.kind == ErrorKind::Interrupted {
440+ stage.report.stop = Some(HistoryRepairStop::Cancelled);
441+ } else if error.kind == ErrorKind::RoomBusy {
442+ stage.report.stop = Some(HistoryRepairStop::Budget);
443+ } else {
444+ stage.report.failure = Some(error);
445+ }
446+ stage.report.continuity = HistoryContinuity::Unresolved;
447+ }
448+ stage.report.phase = if stage.report.stop.is_some() || stage.report.failure.is_some() {
449+ HistoryRepairPhase::Paused
450+ } else {
451+ HistoryRepairPhase::Ready
452+ };
453+ stage
454+}
455+
456+fn contains_anchor(stage: &Stage) -> bool {
457+ stage.anchor.as_ref().is_some_and(|anchor| {
458+ stage
459+ .events
460+ .iter()
461+ .any(|event| event.event_id().as_ref() == Some(anchor))
462+ })
463+}
464+
465+fn deduplicate(events: &mut Vec<TimelineEvent>) {
466+ let mut seen = HashSet::new();
467+ events.retain(|event| event.event_id().is_some_and(|id| seen.insert(id)));
468+}
469+
470+fn extend_head(events: &mut Vec<TimelineEvent>, head: Vec<TimelineEvent>) -> bool {
471+ let ids: HashSet<_> = events.iter().filter_map(TimelineEvent::event_id).collect();
472+ let Some(overlap) = head
473+ .iter()
474+ .position(|event| event.event_id().is_some_and(|id| ids.contains(&id)))
475+ else {
476+ return events.is_empty() && head.is_empty();
477+ };
478+ events.extend(head.into_iter().skip(overlap + 1));
479+ deduplicate(events);
480+ true
481+}
482+
483+async fn install(installs: &mpsc::Sender<InstallRequest>, stage: &Stage) -> Result<(), Error> {
484+ let (reply, response) = oneshot::channel();
485+ installs
486+ .send(InstallRequest {
487+ events: stage.events.clone(),
488+ prev_batch: stage.cursor.clone(),
489+ reply,
490+ })
491+ .await
492+ .map_err(|_| Error::interrupted())?;
493+ response.await.map_err(|_| Error::interrupted())?
494+}
495+
496+async fn fetch(
497+ room: &Room,
498+ from: Option<&str>,
499+ cancel: &mut watch::Receiver<bool>,
500+ started: &Instant,
501+ budget: &HistoryRepairBudget,
502+) -> Result<matrix_sdk::room::Messages, Error> {
503+ let remaining = Duration::from_millis(budget.max_duration_ms).saturating_sub(started.elapsed());
504+ if *cancel.borrow() {
505+ return Err(Error::interrupted());
506+ }
507+ let mut options = MessagesOptions::backward().from(from);
508+ options.limit = PAGE_SIZE.into();
509+ tokio::select! {
510+ _ = cancel.changed() => Err(Error::interrupted()),
511+ result = matrix_sdk::timeout::timeout(room.messages(options), remaining) => {
512+ let page = result.map_err(|_| Error::new(ErrorKind::RoomBusy, "repair work duration exhausted"))?
513+ .map_err(|source| Error::from_sdk("fetching repair history failed", source))?;
514+ if page.chunk.len() > usize::from(PAGE_SIZE) {
515+ return Err(Error::new(ErrorKind::Invalid, "server exceeded the repair page limit"));
516+ }
517+ Ok(page)
518+ }
519+ }
520+}
521+
522+struct SyncPause {
523+ sync: Arc<SyncService>,
524+ shutdown: Arc<AtomicBool>,
525+ gate: Option<OwnedMutexGuard<()>>,
526+}
527+
528+impl SyncPause {
529+ async fn finish(mut self) {
530+ if !self.shutdown.load(Ordering::Acquire) {
531+ self.sync.start().await;
532+ }
533+ self.gate.take();
534+ }
535+}
536+
537+impl Drop for SyncPause {
538+ fn drop(&mut self) {
539+ if let Some(gate) = self.gate.take() {
540+ let sync = self.sync.clone();
541+ let shutdown = self.shutdown.clone();
542+ spawn(async move {
543+ if !shutdown.load(Ordering::Acquire) {
544+ sync.start().await;
545+ }
546+ drop(gate);
547+ });
548+ }
549+ }
550+}
551+
552+async fn pause_sync(
553+ context: &SessionContext,
554+ cancel: &mut watch::Receiver<bool>,
555+ started: &Instant,
556+ budget: &HistoryRepairBudget,
557+) -> Result<SyncPause, Error> {
558+ let remaining = Duration::from_millis(budget.max_duration_ms).saturating_sub(started.elapsed());
559+ let gate = tokio::select! {
560+ _ = cancel.changed() => return Err(Error::interrupted()),
561+ result = matrix_sdk::timeout::timeout(context.repair_gate.clone().lock_owned(), remaining) =>
562+ result.map_err(|_| Error::new(ErrorKind::RoomBusy, "repair work duration exhausted"))?,
563+ };
564+ if context.repair_shutdown.load(Ordering::Acquire) {
565+ return Err(Error::interrupted());
566+ }
567+ let guard = SyncPause {
568+ sync: context.sync.clone(),
569+ shutdown: context.repair_shutdown.clone(),
570+ gate: Some(gate),
571+ };
572+ context.sync.stop().await;
573+ Ok(guard)
574+}
575+
576+fn cache_error(source: matrix_sdk::event_cache::EventCacheError) -> Error {
577+ Error::with_source(
578+ ErrorKind::Store,
579+ "room history cache operation failed",
580+ source,
581+ )
582+}
583+
584+#[cfg(test)]
585+mod tests {
586+ use super::*;
587+
588+ #[test]
589+ fn threshold_requires_known_coverage_and_an_independent_boundary() {
590+ let policy = HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 100 };
591+ assert!(should_bridge(&policy, Some(100), true));
592+ assert!(should_bridge(&policy, Some(99), true));
593+ assert!(!should_bridge(&policy, Some(101), true));
594+ assert!(!should_bridge(&policy, None, true));
595+ assert!(!should_bridge(&policy, Some(1), false));
596+ assert!(!should_bridge(
597+ &HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 0 },
598+ Some(0),
599+ true
600+ ));
601+ assert!(should_bridge(&HistoryRepairPolicy::Bridge, None, false));
602+ assert!(!should_bridge(
603+ &HistoryRepairPolicy::StartRecent,
604+ Some(1),
605+ true
606+ ));
607+ }
608+}
Modified file: src/engine/room.rs+106 additions−4 deletions
@@ -6,22 +6,28 @@ use ruma::OwnedRoomId;
66 use tokio::sync::mpsc;
77
88 use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};
9+use super::repair::{InstallRequest, RepairController};
910 use super::timeline::{SessionContext, TaskGuard, TimelineSession};
1011 use crate::{
11- Error, ErrorKind, Event, TimelineCommand, TimelineId, TypingState, transport::Responder,
12+ Error, ErrorKind, Event, HistoryRepairCommand, TimelineCommand, TimelineId, TypingState,
13+ transport::Responder,
1214 };
1315
1416 pub(super) enum ActorRequest {
1517 /// opening an open room answers with a fresh reset
1618 Open(Responder),
1719 Execute(TimelineCommand, Responder),
20+ Repair(HistoryRepairCommand, Responder),
1821 Close(Responder),
1922 }
2023
2124 impl ActorRequest {
2225 pub(super) fn into_reply(self) -> Responder {
2326 match self {
24- Self::Open(reply) | Self::Execute(_, reply) | Self::Close(reply) => reply,
27+ Self::Open(reply)
28+ | Self::Execute(_, reply)
29+ | Self::Repair(_, reply)
30+ | Self::Close(reply) => reply,
2531 }
2632 }
2733 }
@@ -100,7 +106,9 @@ impl RoomActor {
100106 threads: Threads::new(self.requests.max_capacity()),
101107 work: TaskSet::new(),
102108 };
103- let close = self.serve_open(&mut timelines).await;
109+ let mut repair = RepairController::new(self.room.clone(), self.context.clone());
110+ let close = self.serve_open(&mut timelines, &mut repair).await;
111+ repair.shutdown().await;
104112 if close.is_none() {
105113 self.requests.close();
106114 let _ = self.notes.send(ActorNote::Leaving {
@@ -130,7 +138,11 @@ impl RoomActor {
130138 }
131139
132140 /// returns the close request's responder, if a close is why we stopped
133- async fn serve_open(&mut self, timelines: &mut Timelines) -> Option<Responder> {
141+ async fn serve_open(
142+ &mut self,
143+ timelines: &mut Timelines,
144+ repair: &mut RepairController,
145+ ) -> Option<Responder> {
134146 let mut room_info = self.room.subscribe_info();
135147 // a leave that landed while the timeline was building won't come round again
136148 if matches!(self.room.state(), RoomState::Left | RoomState::Banned) {
@@ -151,7 +163,15 @@ impl RoomActor {
151163 Some(ActorRequest::Execute(command, reply)) => {
152164 timelines.execute(command, reply, &self.room).await;
153165 }
166+ Some(ActorRequest::Repair(command, reply)) => {
167+ repair.execute(command, reply).await;
168+ }
154169 },
170+ install = repair.next_install(), if repair.is_running() => {
171+ if let Some(install) = install {
172+ timelines.install_history(install, &self.room, &self.context).await;
173+ }
174+ }
155175 Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {
156176 timelines.thread_built(built, &self.room_id, &self.context).await;
157177 }
@@ -220,6 +240,88 @@ struct Timelines {
220240 }
221241
222242 impl Timelines {
243+ async fn install_history(
244+ &mut self,
245+ install: InstallRequest,
246+ room: &Room,
247+ context: &SessionContext,
248+ ) {
249+ let InstallRequest {
250+ events,
251+ prev_batch,
252+ reply,
253+ } = install;
254+ let result = self
255+ .install_history_inner(events, prev_batch, room, context)
256+ .await;
257+ let _ = reply.send(result);
258+ }
259+
260+ async fn install_history_inner(
261+ &mut self,
262+ events: Vec<matrix_sdk::deserialized_responses::TimelineEvent>,
263+ prev_batch: Option<String>,
264+ room: &Room,
265+ context: &SessionContext,
266+ ) -> Result<(), Error> {
267+ let threads_capacity = self.threads.capacity;
268+ self.threads.close().await;
269+ self.threads = Threads::new(threads_capacity);
270+ self.room.pause_for_reseed().await;
271+
272+ let reseed = async {
273+ let (cache, _drop_handles) = room.event_cache().await.map_err(|source| {
274+ Error::with_source(ErrorKind::Timeline, "history event cache failed", source)
275+ })?;
276+ cache.reseed(events, prev_batch).await.map_err(|source| {
277+ Error::with_source(ErrorKind::Timeline, "history cache reseed failed", source)
278+ })
279+ }
280+ .await;
281+ if let Err(error) = reseed {
282+ return match self.room.resume_after_failed_reseed().await {
283+ Ok(()) => Err(error),
284+ Err(restart_error) => Err(restart_error),
285+ };
286+ }
287+
288+ let timeline = match build_timeline(
289+ room,
290+ TimelineFocus::Live {
291+ hide_threaded_events: true,
292+ },
293+ )
294+ .await
295+ {
296+ Ok(timeline) => timeline,
297+ Err(source) => {
298+ let error =
299+ Error::with_source(ErrorKind::Timeline, "room timeline rebuild failed", source);
300+ return match self.room.resume_after_failed_reseed().await {
301+ Ok(()) => Err(error),
302+ Err(restart_error) => Err(restart_error),
303+ };
304+ }
305+ };
306+ let timeline_id = self.room.id.clone();
307+ let mut context = context.clone();
308+ context.initial_history_limit = 0;
309+ let timeline = match TimelineSession::start(timeline_id, timeline, context).await {
310+ Ok(timeline) => timeline,
311+ Err(source) => {
312+ let error =
313+ Error::with_source(ErrorKind::Timeline, "room timeline reset failed", source);
314+ return match self.room.resume_after_failed_reseed().await {
315+ Ok(()) => Err(error),
316+ Err(restart_error) => Err(restart_error),
317+ };
318+ }
319+ };
320+ let previous = std::mem::replace(&mut self.room, timeline);
321+ drop(previous);
322+ Ok(())
323+ }
324+
223325 async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {
224326 match command {
225327 TimelineCommand::OpenThread {
Modified file: src/engine/timeline.rs+50 additions−14 deletions
@@ -12,9 +12,10 @@ use matrix_sdk::{
1212 },
1313 },
1414 };
15+use matrix_sdk_ui::sync_service::SyncService;
1516 use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};
1617 use ruma::{OwnedEventId, OwnedUserId};
17-use tokio::sync::mpsc;
18+use tokio::sync::{Mutex, mpsc};
1819
1920 use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};
2021 use crate::{
@@ -66,6 +67,10 @@ impl Drop for TaskGuard {
6667 pub(super) struct SessionContext {
6768 pub(super) events: EventSender,
6869 pub(super) media: Arc<MediaService>,
70+ pub(super) mentions: crate::mentions::service::Handle,
71+ pub(super) sync: Arc<SyncService>,
72+ pub(super) repair_gate: Arc<Mutex<()>>,
73+ pub(super) repair_shutdown: Arc<std::sync::atomic::AtomicBool>,
6974 pub(super) current_user_id: Option<OwnedUserId>,
7075 pub(super) initial_history_limit: u16,
7176 pub(super) supervisor: Supervisor,
@@ -96,16 +101,7 @@ impl TimelineSession {
96101 let timeline = Arc::new(timeline);
97102 let scope = MediaScope::new();
98103 let watcher = start_watcher(&id, &timeline, &context, &scope).await?;
99- let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);
100- let pagination_worker = context.supervisor.spawn(
101- "pagination worker",
102- run_pagination(
103- requests,
104- timeline.clone(),
105- context.events.clone(),
106- id.clone(),
107- ),
108- );
104+ let (pagination, pagination_worker) = start_pagination_worker(&id, &timeline, &context);
109105 // under lazy-loaded members the sdk leaves sender profiles unavailable until someone
110106 // asks, and a /members fetched any other way never reaches the timeline's items
111107 let member_fetch = context.supervisor.spawn("member fetch", {
@@ -157,15 +153,37 @@ impl TimelineSession {
157153 }
158154 }
159155
160- /// pending paginations answer `Interrupted` as their responders drop
161- pub(super) async fn close(mut self) {
156+ pub(super) async fn pause_for_reseed(&mut self) {
157+ self.stop_without_close().await;
158+ }
159+
160+ async fn stop_without_close(&mut self) {
162161 self.member_fetch.abort();
163162 self.pagination_worker.abort();
164- let _ = (&mut self.pagination_worker).await;
165163 self.watcher.abort();
164+ let _ = (&mut self.member_fetch).await;
165+ let _ = (&mut self.pagination_worker).await;
166166 let _ = (&mut self.watcher).await;
167167 // after the watcher, or a last diff could register media for a timeline that's gone
168168 self.context.media.release(&self.scope).await;
169+ }
170+
171+ pub(super) async fn resume_after_failed_reseed(&mut self) -> Result<(), Error> {
172+ self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)
173+ .await
174+ .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;
175+ (self.pagination, self.pagination_worker) =
176+ start_pagination_worker(&self.id, &self.timeline, &self.context);
177+ self.member_fetch = self.context.supervisor.spawn("member fetch", {
178+ let timeline = self.timeline.clone();
179+ async move { timeline.fetch_members().await }
180+ });
181+ Ok(())
182+ }
183+
184+ /// pending paginations answer `Interrupted` as their responders drop
185+ pub(super) async fn close(mut self) {
186+ self.stop_without_close().await;
169187 let _ = self
170188 .context
171189 .events
@@ -339,6 +357,24 @@ impl TimelineSession {
339357 }
340358 }
341359
360+fn start_pagination_worker(
361+ id: &TimelineId,
362+ timeline: &Arc<Timeline>,
363+ context: &SessionContext,
364+) -> (mpsc::Sender<PaginationRequest>, JoinHandle<()>) {
365+ let (pagination, requests) = mpsc::channel(PAGINATION_QUEUE);
366+ let worker = context.supervisor.spawn(
367+ "pagination worker",
368+ run_pagination(
369+ requests,
370+ timeline.clone(),
371+ context.events.clone(),
372+ id.clone(),
373+ ),
374+ );
375+ (pagination, worker)
376+}
377+
342378 impl Drop for TimelineSession {
343379 fn drop(&mut self) {
344380 self.watcher.abort();
Modified file: src/lib.rs+7 additions
@@ -9,6 +9,7 @@ mod mentions_model;
99 mod mirror;
1010 mod model;
1111 mod normalise;
12+mod repair_model;
1213 mod space_order;
1314 mod transport;
1415 mod uiaa;
@@ -71,7 +72,13 @@ pub use model::{
7172 UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
7273 VerificationFailure, VerificationState, VerificationTarget,
7374 };
75+pub use repair_model::{
76+ HistoryContinuity, HistoryRepairBudget, HistoryRepairCommand, HistoryRepairPhase,
77+ HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop, HistoryRepairStrategy,
78+};
7479 pub use ruma;
80+#[doc(hidden)]
81+pub use transport::PublicationAck;
7582 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
7683 pub use uiaa::{
7784 InteractiveAuth, InteractiveAuthChallenge, InteractiveAuthFailure, InteractiveAuthFlow,
Modified file: src/mentions/service.rs+105 additions−6 deletions
@@ -10,18 +10,18 @@ use matrix_sdk::{
1010 sliding_sync::{ResponseCommitBatch, ResponseCommitHook},
1111 };
1212 use ruma::{
13- OwnedRoomId, OwnedUserId,
13+ OwnedEventId, OwnedRoomId, OwnedUserId,
1414 events::{AnySyncTimelineEvent, room::encrypted::OriginalSyncRoomEncryptedEvent},
1515 serde::Raw,
1616 };
1717 use serde_json::Value;
1818 use tokio::sync::{mpsc, oneshot, watch};
1919
20-use super::state::{Candidate, Data, SavedEvent, Window, id, invalid, now};
20+use super::state::{Candidate, Data, RepairCoverage, SavedEvent, Window, id, invalid, now};
2121 use crate::{
22- CommandOutput, EngineConfig, Error, ErrorKind, Event, MentionCaptureState, MentionEntry,
23- MentionGap, MentionKey, MentionReason, MentionsCommand, MentionsPage, MentionsUpdate,
24- TimelineContent, TimelineItem,
22+ CommandOutput, EngineConfig, Error, ErrorKind, Event, HistoryRepairPolicy, MentionCaptureState,
23+ MentionEntry, MentionGap, MentionKey, MentionReason, MentionsCommand, MentionsPage,
24+ MentionsUpdate, TimelineContent, TimelineItem,
2525 engine::{MediaService, MentionsStore, context, executor::EngineBoxFuture},
2626 normalise::{MediaRegistration, normalise_observed},
2727 transport::EventSender,
@@ -40,6 +40,10 @@ enum Request {
4040 MentionsCommand,
4141 oneshot::Sender<Result<CommandOutput, Error>>,
4242 ),
43+ GetRepairPolicy(oneshot::Sender<Result<HistoryRepairPolicy, Error>>),
44+ SetRepairPolicy(HistoryRepairPolicy, oneshot::Sender<Result<(), Error>>),
45+ GetRepairCoverage(OwnedRoomId, oneshot::Sender<Result<RepairCoverage, Error>>),
46+ RepairResolved(OwnedRoomId, oneshot::Sender<Result<(), Error>>),
4347 Stop(oneshot::Sender<()>),
4448 }
4549
@@ -100,6 +104,45 @@ impl Handle {
100104 response.await.map_err(|_| Error::interrupted())?
101105 }
102106
107+ pub(crate) async fn repair_policy(&self) -> Result<HistoryRepairPolicy, Error> {
108+ let (reply, response) = oneshot::channel();
109+ self.0
110+ .send(Request::GetRepairPolicy(reply))
111+ .await
112+ .map_err(|_| Error::interrupted())?;
113+ response.await.map_err(|_| Error::interrupted())?
114+ }
115+
116+ pub(crate) async fn set_repair_policy(&self, policy: HistoryRepairPolicy) -> Result<(), Error> {
117+ let (reply, response) = oneshot::channel();
118+ self.0
119+ .send(Request::SetRepairPolicy(policy, reply))
120+ .await
121+ .map_err(|_| Error::interrupted())?;
122+ response.await.map_err(|_| Error::interrupted())?
123+ }
124+
125+ pub(crate) async fn repair_coverage(
126+ &self,
127+ room_id: OwnedRoomId,
128+ ) -> Result<RepairCoverage, Error> {
129+ let (reply, response) = oneshot::channel();
130+ self.0
131+ .send(Request::GetRepairCoverage(room_id, reply))
132+ .await
133+ .map_err(|_| Error::interrupted())?;
134+ response.await.map_err(|_| Error::interrupted())?
135+ }
136+
137+ pub(crate) async fn repair_resolved(&self, room_id: OwnedRoomId) -> Result<(), Error> {
138+ let (reply, response) = oneshot::channel();
139+ self.0
140+ .send(Request::RepairResolved(room_id, reply))
141+ .await
142+ .map_err(|_| Error::interrupted())?;
143+ response.await.map_err(|_| Error::interrupted())?
144+ }
145+
103146 pub(crate) async fn stop(&self) {
104147 let (reply, response) = oneshot::channel();
105148 if self.0.send(Request::Stop(reply)).await.is_ok() {
@@ -225,6 +268,23 @@ impl Service {
225268 Ok(())
226269 }
227270
271+ async fn set_repair_policy(&mut self, policy: HistoryRepairPolicy) -> Result<(), Error> {
272+ let mut next = self.data.clone();
273+ next.repair_policy = policy;
274+ self.save(next).await
275+ }
276+
277+ async fn repair_resolved(&mut self, room_id: OwnedRoomId) -> Result<(), Error> {
278+ let mut next = self.data.clone();
279+ let coverage = next.repair_coverage.entry(room_id.clone()).or_default();
280+ coverage.boundary_ms = None;
281+ coverage.last_confirmed_ms = Some(now());
282+ coverage.reliable = false;
283+ coverage.unresolved = false;
284+ next.observed_rooms.insert(room_id);
285+ self.save(next).await
286+ }
287+
228288 async fn command(&mut self, command: MentionsCommand) -> Result<CommandOutput, Error> {
229289 match command {
230290 MentionsCommand::Page { cursor, limit } => {
@@ -340,13 +400,20 @@ impl Service {
340400 if next.checkpoints.get(&batch.connection_id) == Some(&batch.next_pos) {
341401 return Ok(());
342402 }
343- if next.checkpoints.get(&batch.connection_id) != batch.previous_pos.as_ref() {
403+ let observed_at_ms = now();
404+ let checkpoint_discontinuity =
405+ next.checkpoints.get(&batch.connection_id) != batch.previous_pos.as_ref();
406+ if checkpoint_discontinuity {
344407 Self::gap(
345408 &mut next,
346409 None,
347410 "sync checkpoint continuity is unknown",
348411 None,
349412 );
413+ let observed_rooms: Vec<_> = next.observed_rooms.iter().cloned().collect();
414+ for room_id in observed_rooms {
415+ Self::mark_unresolved(&mut next, &room_id, observed_at_ms);
416+ }
350417 }
351418 for (room_id, raw) in &batch.raw_rooms {
352419 if raw.limited || !next.observed_rooms.contains(room_id) {
@@ -376,8 +443,20 @@ impl Service {
376443 {
377444 continue;
378445 }
446+ let previously_observed = next.observed_rooms.contains(room_id);
447+ let limited = batch.raw_rooms.get(room_id).is_some_and(|raw| raw.limited);
448+ if checkpoint_discontinuity || limited || !previously_observed {
449+ Self::mark_unresolved(&mut next, room_id, observed_at_ms);
450+ } else {
451+ let coverage = next.repair_coverage.entry(room_id.clone()).or_default();
452+ if !coverage.unresolved {
453+ coverage.last_confirmed_ms = Some(observed_at_ms);
454+ }
455+ coverage.reliable = false;
456+ }
379457 next.observed_rooms.insert(room_id.clone());
380458 let mut observed = BTreeMap::new();
459+ let mut latest_anchor = None;
381460 if let Some(raw) = batch.raw_rooms.get(room_id) {
382461 for event in &raw.timeline {
383462 let value: Value =
@@ -385,10 +464,17 @@ impl Service {
385464 Error::with_source(ErrorKind::Invalid, "invalid observed event", error)
386465 })?;
387466 if let Some(event_id) = value["event_id"].as_str() {
467+ latest_anchor = event_id.parse::<OwnedEventId>().ok();
388468 observed.insert(event_id.to_owned(), value);
389469 }
390470 }
391471 }
472+ if let Some(latest_anchor) = latest_anchor {
473+ next.repair_coverage
474+ .entry(room_id.clone())
475+ .or_default()
476+ .latest_anchor = Some(latest_anchor);
477+ }
392478 if let Some(update) = batch.response.rooms.joined.get(room_id) {
393479 for event in &update.timeline.events {
394480 let value: Value =
@@ -443,6 +529,15 @@ impl Service {
443529 self.save(next).await
444530 }
445531
532+ fn mark_unresolved(data: &mut Data, room_id: &OwnedRoomId, observed_at_ms: u64) {
533+ let coverage = data.repair_coverage.entry(room_id.clone()).or_default();
534+ if !coverage.unresolved {
535+ coverage.boundary_ms = Some(observed_at_ms);
536+ coverage.unresolved = true;
537+ }
538+ coverage.reliable = false;
539+ }
540+
446541 async fn filter_ignored(&self, next: &mut Data) {
447542 let mut removed = Vec::new();
448543 for (key, entry) in &next.entries {
@@ -853,6 +948,10 @@ impl Service {
853948 Some(Request::Stop(reply)) => { let _ = reply.send(()); return; }
854949 Some(Request::Batch(batch, reply)) => { let result = self.batch(batch).await; let _ = reply.send(result); }
855950 Some(Request::Command(command, reply)) => { let result = self.command(command).await; let _ = reply.send(result); }
951+ Some(Request::GetRepairPolicy(reply)) => { let _ = reply.send(Ok(self.data.repair_policy.clone())); }
952+ Some(Request::SetRepairPolicy(policy, reply)) => { let result = self.set_repair_policy(policy).await; let _ = reply.send(result); }
953+ Some(Request::GetRepairCoverage(room_id, reply)) => { let coverage = self.data.repair_coverage.get(&room_id).cloned().unwrap_or_default(); let _ = reply.send(Ok(coverage)); }
954+ Some(Request::RepairResolved(room_id, reply)) => { let result = self.repair_resolved(room_id).await; let _ = reply.send(result); }
856955 None => return,
857956 },
858957 Some(result) = work.next(), if !work.is_empty() => {
Modified file: src/mentions/service_tests.rs+139 additions
@@ -171,6 +171,145 @@ async fn failed_batch_keeps_inbox_and_checkpoint_at_the_same_commit_boundary() {
171171 config.delete().await.unwrap();
172172 }
173173
174+#[test]
175+fn repair_fields_default_when_loading_an_older_inbox() {
176+ let mut inbox = serde_json::to_value(Data::default()).unwrap();
177+ inbox.as_object_mut().unwrap().remove("repair_policy");
178+ inbox.as_object_mut().unwrap().remove("repair_coverage");
179+
180+ let restored: Data = serde_json::from_value(inbox).unwrap();
181+ assert_eq!(restored.repair_policy, HistoryRepairPolicy::default());
182+ assert!(restored.repair_coverage.is_empty());
183+}
184+
185+#[tokio::test]
186+async fn repair_policy_and_coverage_roundtrip_through_the_inbox() {
187+ let server = MatrixMockServer::new().await;
188+ let room_id = room_id!("!repair-coverage:localhost");
189+ let (client, _) = joined_client(&server, room_id).await;
190+ let config = config();
191+ let store = MentionsStore::open(&config, account(&client), MAX_BYTES)
192+ .await
193+ .unwrap();
194+ let media = Arc::new(MediaService::new(
195+ client.clone(),
196+ EngineConfig::default().max_media_bytes,
197+ EngineConfig::default().max_media_sources,
198+ ));
199+ let (handle, mut service, _) =
200+ Service::new(client.clone(), media, Some(store), EngineConfig::default())
201+ .await
202+ .unwrap();
203+ service.recovery_budget = 0;
204+ service.latest_scan = false;
205+ let service_task = tokio::spawn(service.run());
206+
207+ let default_policy = HistoryRepairPolicy::default();
208+ assert_eq!(handle.repair_policy().await.unwrap(), default_policy);
209+ let stored_policy = HistoryRepairPolicy::Bridge;
210+ handle
211+ .set_repair_policy(stored_policy.clone())
212+ .await
213+ .unwrap();
214+ handle.repair_resolved(room_id.to_owned()).await.unwrap();
215+ let coverage = handle.repair_coverage(room_id.to_owned()).await.unwrap();
216+ assert!(coverage.last_confirmed_ms.is_some());
217+ assert!(!coverage.reliable);
218+ assert_eq!(coverage.duration_ms(), None);
219+
220+ handle.stop().await;
221+ service_task.await.unwrap();
222+ drop(handle);
223+
224+ let reopened = MentionsStore::open(&config, account(&client), MAX_BYTES)
225+ .await
226+ .unwrap();
227+ let persisted: Data = reopened.read("inbox").unwrap().unwrap();
228+ assert_eq!(persisted.repair_policy, stored_policy);
229+ let coverage = persisted.repair_coverage.get(room_id).unwrap();
230+ assert!(coverage.last_confirmed_ms.is_some());
231+ assert!(coverage.boundary_ms.is_none());
232+ assert!(!coverage.unresolved);
233+ assert!(!coverage.reliable);
234+ drop(reopened);
235+ config.delete().await.unwrap();
236+}
237+
238+#[tokio::test]
239+async fn repair_limited_sync_freezes_the_last_room_checkpoint() {
240+ let server = MatrixMockServer::new().await;
241+ let room_id = room_id!("!repair-boundary:localhost");
242+ let (client, _) = joined_client(&server, room_id).await;
243+ let media = Arc::new(MediaService::new(
244+ client.clone(),
245+ EngineConfig::default().max_media_bytes,
246+ EngineConfig::default().max_media_sources,
247+ ));
248+ let (_handle, mut service, _) = Service::new(client, media, None, EngineConfig::default())
249+ .await
250+ .unwrap();
251+ service.data.checkpoints.insert(
252+ "mentions-connection".to_owned(),
253+ "position-before".to_owned(),
254+ );
255+ service.data.observed_rooms.insert(room_id.to_owned());
256+ service
257+ .data
258+ .repair_coverage
259+ .entry(room_id.to_owned())
260+ .or_default()
261+ .last_confirmed_ms = Some(1_000);
262+
263+ let events = [
264+ EventFactory::new()
265+ .room(room_id)
266+ .text_msg("older event")
267+ .sender(user_id!("@sender:localhost"))
268+ .event_id(event_id!("$repair-older:localhost"))
269+ .into_raw_sync(),
270+ EventFactory::new()
271+ .room(room_id)
272+ .text_msg("latest event")
273+ .sender(user_id!("@sender:localhost"))
274+ .event_id(event_id!("$repair-latest:localhost"))
275+ .into_raw_sync(),
276+ ];
277+ let timeline: Vec<_> = events
278+ .iter()
279+ .map(|event| serde_json::to_value(event).unwrap())
280+ .collect();
281+ let raw_room = serde_json::from_value(json!({
282+ "limited": true,
283+ "prev_batch": "repair-gap-token",
284+ "timeline": timeline,
285+ }))
286+ .unwrap();
287+ let batch = ResponseCommitBatch {
288+ connection_id: "mentions-connection".to_owned(),
289+ previous_pos: Some("position-before".to_owned()),
290+ next_pos: "position-after".to_owned(),
291+ response: Default::default(),
292+ raw_rooms: [(room_id.to_owned(), raw_room)].into(),
293+ };
294+
295+ service.batch(batch).await.unwrap();
296+
297+ let coverage = service.data.repair_coverage.get(room_id).unwrap();
298+ assert_eq!(coverage.last_confirmed_ms, Some(1_000));
299+ assert!(
300+ coverage
301+ .boundary_ms
302+ .is_some_and(|boundary| boundary > 1_000)
303+ );
304+ assert!(coverage.unresolved);
305+ assert!(!coverage.reliable);
306+ assert_eq!(coverage.duration_ms(), None);
307+ assert_eq!(
308+ coverage.latest_anchor.as_deref(),
309+ Some(event_id!("$repair-latest:localhost"))
310+ );
311+}
312+
174313 #[tokio::test]
175314 async fn service_recovers_notification_history_into_durable_unread_inbox() {
176315 let server = MatrixMockServer::new().await;
Modified file: src/mentions/state.rs+26 additions−3 deletions
@@ -1,11 +1,11 @@
11 use std::collections::{BTreeMap, BTreeSet};
22
3-use ruma::OwnedUserId;
3+use ruma::{OwnedEventId, OwnedUserId};
44 use serde_json::Value;
55
66 use crate::{
7- Error, ErrorKind, EventContext, MentionCaptureState, MentionCoverage, MentionEntry, MentionKey,
8- MentionsBoundary, MentionsPage, SavedMentionContext, TimelineItem,
7+ Error, ErrorKind, EventContext, HistoryRepairPolicy, MentionCaptureState, MentionCoverage,
8+ MentionEntry, MentionKey, MentionsBoundary, MentionsPage, SavedMentionContext, TimelineItem,
99 normalise::MediaRegistration,
1010 };
1111
@@ -36,6 +36,25 @@ pub(super) struct Window {
3636 pub end: Option<String>,
3737 }
3838
39+#[derive(Clone, Debug, Default)]
40+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41+pub(crate) struct RepairCoverage {
42+ pub(crate) last_confirmed_ms: Option<u64>,
43+ pub(crate) boundary_ms: Option<u64>,
44+ pub(crate) reliable: bool,
45+ pub(crate) unresolved: bool,
46+ pub(crate) latest_anchor: Option<OwnedEventId>,
47+}
48+
49+impl RepairCoverage {
50+ pub(crate) fn duration_ms(&self) -> Option<u64> {
51+ if !self.reliable {
52+ return None;
53+ }
54+ self.boundary_ms?.checked_sub(self.last_confirmed_ms?)
55+ }
56+}
57+
3958 #[derive(Clone, Default)]
4059 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
4160 pub(super) struct Data {
@@ -50,6 +69,10 @@ pub(super) struct Data {
5069 pub coverage: MentionCoverage,
5170 pub checkpoints: BTreeMap<String, String>,
5271 pub observed_rooms: BTreeSet<ruma::OwnedRoomId>,
72+ #[cfg_attr(feature = "serde", serde(default))]
73+ pub repair_policy: HistoryRepairPolicy,
74+ #[cfg_attr(feature = "serde", serde(default))]
75+ pub repair_coverage: BTreeMap<ruma::OwnedRoomId, RepairCoverage>,
5376 pub history_cursor: Option<String>,
5477 pub history_exhausted: bool,
5578 pub latest_cursor: Option<String>,
Modified file: src/mirror.rs+25 additions−3 deletions
@@ -20,9 +20,9 @@ use tokio::sync::broadcast;
2020
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23- HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,
24- TypingState, UploadOutcome, VerificationState, VerificationTarget,
25- engine::executor::EngineBoxFuture,
23+ HistoryRepairReport, HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff,
24+ TimelineId, TimelineItem, TypingState, UploadOutcome, VerificationState, VerificationTarget,
25+ engine::executor::EngineBoxFuture, transport::PublicationRegistration,
2626 };
2727
2828 #[cfg(not(target_family = "wasm"))]
@@ -49,6 +49,7 @@ pub struct State {
4949 pub rooms: Vector<RoomSummary>,
5050 pub image_packs: ImagePacks,
5151 pub timelines: imbl::HashMap<TimelineId, TimelineState>,
52+ pub repairs: imbl::HashMap<OwnedRoomId, HistoryRepairReport>,
5253 pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,
5354 pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,
5455 /// what stopped the engine, if something went wrong
@@ -135,6 +136,7 @@ pub enum StateChange {
135136 timeline_id: TimelineId,
136137 state: HistoryState,
137138 },
139+ HistoryRepair(HistoryRepairReport),
138140 Typing(TypingState),
139141 Upload {
140142 transaction_id: OwnedTransactionId,
@@ -168,6 +170,7 @@ pub struct Mirror {
168170 handle: ClientHandle,
169171 events: EngineBoxStream<Event>,
170172 shared: Arc<Shared>,
173+ _publication: PublicationRegistration,
171174 resyncs: HashMap<TimelineId, AbortHandle>,
172175 rooms_fetch: Option<AbortHandle>,
173176 packs_fetch: Option<AbortHandle>,
@@ -206,6 +209,7 @@ impl Mirror {
206209 rooms: Vector::new(),
207210 image_packs: ImagePacks::default(),
208211 timelines: imbl::HashMap::new(),
212+ repairs: imbl::HashMap::new(),
209213 typing: imbl::HashMap::new(),
210214 uploads: imbl::HashMap::new(),
211215 engine_failure: None,
@@ -220,6 +224,7 @@ impl Mirror {
220224 packs_fetch: Fetch::Idle,
221225 }),
222226 });
227+ let publication = handle.register_publication_mirror();
223228 #[cfg(not(target_family = "wasm"))]
224229 let events = events.boxed();
225230 #[cfg(target_family = "wasm")]
@@ -228,6 +233,7 @@ impl Mirror {
228233 handle,
229234 events,
230235 shared: shared.clone(),
236+ _publication: publication,
231237 resyncs: HashMap::new(),
232238 rooms_fetch: None,
233239 packs_fetch: None,
@@ -357,6 +363,22 @@ impl Mirror {
357363 state: history,
358364 })
359365 }),
366+ Event::HistoryRepairChanged(report) => self.publish(|state| {
367+ state.repairs.insert(report.room_id.clone(), report.clone());
368+ Some(StateChange::HistoryRepair(report))
369+ }),
370+ Event::HistoryRepairPublished {
371+ report,
372+ acknowledgement,
373+ } => {
374+ self.publish(|state| {
375+ state.repairs.insert(report.room_id.clone(), report.clone());
376+ Some(StateChange::HistoryRepair(report))
377+ });
378+ if let Some(acknowledgement) = acknowledgement {
379+ acknowledgement.complete(true);
380+ }
381+ }
360382 Event::TimelineClosed { timeline_id } => {
361383 self.cancel_resync(&timeline_id);
362384 self.publish(|state| {
Modified file: src/model.rs+12 additions−2 deletions
@@ -30,6 +30,7 @@ pub enum Command {
3030 Notification(NotificationCommand),
3131 Lifecycle(LifecycleCommand),
3232 Pack(PackCommand),
33+ HistoryRepair(crate::HistoryRepairCommand),
3334 }
3435
3536 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -653,7 +654,7 @@ pub enum LifecycleCommand {
653654 Shutdown,
654655 }
655656
656-#[derive(Clone, Debug, Eq, PartialEq)]
657+#[derive(Clone, Debug, PartialEq)]
657658 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
658659 pub enum CommandOutput {
659660 Done,
@@ -668,6 +669,8 @@ pub enum CommandOutput {
668669 EventContextPage(EventContextPage),
669670 MentionsPage(crate::MentionsPage),
670671 MentionContext(crate::SavedMentionContext),
672+ HistoryRepair(crate::HistoryRepairReport),
673+ HistoryRepairPolicy(crate::HistoryRepairPolicy),
671674 }
672675
673676 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -864,7 +867,7 @@ impl MessageMentions {
864867 }
865868 }
866869
867-#[derive(Clone, Debug, Eq, PartialEq)]
870+#[derive(Clone, Debug, PartialEq)]
868871 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
869872 pub enum Event {
870873 ConnectionChanged(ConnectionState),
@@ -904,6 +907,13 @@ pub enum Event {
904907 timeline_id: TimelineId,
905908 state: HistoryState,
906909 },
910+ HistoryRepairChanged(crate::HistoryRepairReport),
911+ #[doc(hidden)]
912+ HistoryRepairPublished {
913+ report: crate::HistoryRepairReport,
914+ #[cfg_attr(feature = "serde", serde(skip))]
915+ acknowledgement: Option<crate::PublicationAck>,
916+ },
907917 /// the timeline was closed, or its room was left, and nothing more comes for it
908918 /// until another `TimelineReset`
909919 TimelineClosed {
Added file: src/repair_model.rs+152 additions
@@ -0,0 +1,152 @@
1+use ruma::OwnedRoomId;
2+
3+#[cfg(feature = "serde")]
4+use serde::{Deserialize, Serialize};
5+
6+use crate::Error;
7+
8+#[derive(Clone, Debug, PartialEq)]
9+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10+pub enum HistoryRepairPolicy {
11+ StartRecent,
12+ Bridge,
13+ BridgeShortGaps { threshold_ms: u64 },
14+}
15+
16+impl Default for HistoryRepairPolicy {
17+ fn default() -> Self {
18+ Self::StartRecent
19+ }
20+}
21+
22+#[derive(Clone, Debug, PartialEq)]
23+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24+pub struct HistoryRepairBudget {
25+ pub max_pages: u16,
26+ pub max_events: u32,
27+ pub max_duration_ms: u64,
28+}
29+
30+impl Default for HistoryRepairBudget {
31+ fn default() -> Self {
32+ Self {
33+ max_pages: 10,
34+ max_events: 1000,
35+ max_duration_ms: 15_000,
36+ }
37+ }
38+}
39+
40+#[derive(Clone, Debug, PartialEq)]
41+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42+pub enum HistoryRepairCommand {
43+ Start {
44+ room_id: OwnedRoomId,
45+ policy: Option<HistoryRepairPolicy>,
46+ budget: HistoryRepairBudget,
47+ },
48+ Continue {
49+ room_id: OwnedRoomId,
50+ budget: HistoryRepairBudget,
51+ },
52+ Cancel {
53+ room_id: OwnedRoomId,
54+ },
55+ StartRecent {
56+ room_id: OwnedRoomId,
57+ },
58+ GetPolicy,
59+ SetPolicy(HistoryRepairPolicy),
60+}
61+
62+#[derive(Clone, Debug, PartialEq)]
63+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
64+pub enum HistoryRepairStrategy {
65+ Recent,
66+ Overlap,
67+ Bridge,
68+}
69+
70+#[derive(Clone, Debug, PartialEq)]
71+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
72+pub enum HistoryContinuity {
73+ Unresolved,
74+ Verified,
75+ AccessibleStart,
76+}
77+
78+#[derive(Clone, Debug, PartialEq)]
79+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
80+pub enum HistoryRepairPhase {
81+ FetchingRecent,
82+ Bridging,
83+ Ready,
84+ Paused,
85+}
86+
87+#[derive(Clone, Debug, PartialEq)]
88+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
89+pub enum HistoryRepairStop {
90+ Cancelled,
91+ Budget,
92+ HistoryUnavailable,
93+ NoAnchor,
94+ CursorStalled,
95+}
96+
97+#[derive(Clone, Debug, PartialEq)]
98+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99+pub struct HistoryRepairReport {
100+ pub room_id: OwnedRoomId,
101+ pub requested_policy: HistoryRepairPolicy,
102+ pub strategy: HistoryRepairStrategy,
103+ pub coverage_duration_ms: Option<u64>,
104+ pub threshold_evaluated: bool,
105+ pub pages: u32,
106+ pub events: u32,
107+ pub phase: HistoryRepairPhase,
108+ pub continuity: HistoryContinuity,
109+ pub recent_available: bool,
110+ pub stop: Option<HistoryRepairStop>,
111+ pub failure: Option<Error>,
112+}
113+
114+#[cfg(all(test, feature = "serde"))]
115+mod tests {
116+ use super::*;
117+ use crate::ErrorKind;
118+ use ruma::room_id;
119+
120+ #[test]
121+ fn history_repair_types_round_trip_through_serde() {
122+ let command = HistoryRepairCommand::Start {
123+ room_id: room_id!("!room:example.org").to_owned(),
124+ policy: Some(HistoryRepairPolicy::BridgeShortGaps {
125+ threshold_ms: 86_400_000,
126+ }),
127+ budget: HistoryRepairBudget::default(),
128+ };
129+ let encoded_command = serde_json::to_value(&command).unwrap();
130+ let decoded_command: HistoryRepairCommand =
131+ serde_json::from_value(encoded_command).unwrap();
132+ assert_eq!(decoded_command, command);
133+
134+ let report = HistoryRepairReport {
135+ room_id: room_id!("!room:example.org").to_owned(),
136+ requested_policy: HistoryRepairPolicy::Bridge,
137+ strategy: HistoryRepairStrategy::Overlap,
138+ coverage_duration_ms: Some(86_400_000),
139+ threshold_evaluated: false,
140+ pages: 3,
141+ events: 42,
142+ phase: HistoryRepairPhase::Paused,
143+ continuity: HistoryContinuity::Unresolved,
144+ recent_available: true,
145+ stop: Some(HistoryRepairStop::Budget),
146+ failure: Some(Error::new(ErrorKind::Timeline, "repair failed")),
147+ };
148+ let encoded_report = serde_json::to_value(&report).unwrap();
149+ let decoded_report: HistoryRepairReport = serde_json::from_value(encoded_report).unwrap();
150+ assert_eq!(decoded_report, report);
151+ }
152+}
Modified file: src/transport.rs+302 additions−15 deletions
@@ -3,7 +3,7 @@ use std::{
33 fmt,
44 future::Future,
55 pin::Pin,
6- sync::{Arc, Mutex},
6+ sync::{Arc, Mutex, Weak},
77 task::{Context, Poll},
88 };
99
@@ -29,15 +29,15 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2929 use crate::{
3030 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
3131 EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventContext, EventContextCommand,
32- EventContextPage, EventRef, ImagePackSource, ImagePacks, InteractiveAuth,
33- InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand, MediaId,
34- MediaLocation, MediaSize, MemberFilter, MemberSummary, MentionKey, MentionsBoundary,
35- MentionsCommand, MentionsPage, MessageContent, MessageEditContent, NotificationCommand,
36- OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile,
37- PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
38- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary,
39- SavedMentionContext, SpaceChild, TimelineCommand, TimelineId, VerificationAction,
40- VerificationTarget,
32+ EventContextPage, EventRef, HistoryRepairBudget, HistoryRepairCommand, HistoryRepairPolicy,
33+ HistoryRepairReport, ImagePackSource, ImagePacks, InteractiveAuth, InteractiveAuthOutcome,
34+ LifecycleCommand, LinkPreview, Media, MediaCommand, MediaId, MediaLocation, MediaSize,
35+ MemberFilter, MemberSummary, MentionKey, MentionsBoundary, MentionsCommand, MentionsPage,
36+ MessageContent, MessageEditContent, NotificationCommand, OwnDevice, PackCommand, PackImageInfo,
37+ PackUsage, Patch, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
38+ PushNotificationStatus, RoomCommand, RoomListFilter, RoomNotificationMode,
39+ RoomNotificationSettings, RoomPowerLevels, RoomSummary, SavedMentionContext, SpaceChild,
40+ TimelineCommand, TimelineId, VerificationAction, VerificationTarget,
4141 encryption::encryption_state,
4242 engine::{MediaService, StoreLock},
4343 model::{PushNotificationRoom, PushNotificationSender},
@@ -54,6 +54,7 @@ pub struct ClientHandle {
5454 media: Arc<MediaService>,
5555 spaces: Arc<OnceCell<SpaceService>>,
5656 members_checked: Arc<Mutex<HashSet<OwnedRoomId>>>,
57+ publication: Arc<PublicationRegistry>,
5758 _store_lock: Option<Arc<StoreLock>>,
5859 }
5960
@@ -81,6 +82,10 @@ impl Future for Pending {
8182 }
8283
8384 impl ClientHandle {
85+ pub(crate) fn register_publication_mirror(&self) -> PublicationRegistration {
86+ self.publication.register_mirror()
87+ }
88+
8489 /// current credentials, it holds secrets. the engine saves it for you unless the
8590 /// session was built `without_persistence`
8691 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
@@ -327,6 +332,84 @@ impl ClientHandle {
327332 .await
328333 }
329334
335+ pub async fn history_repair(
336+ &self,
337+ room_id: OwnedRoomId,
338+ policy: Option<HistoryRepairPolicy>,
339+ budget: HistoryRepairBudget,
340+ ) -> Result<HistoryRepairReport, Error> {
341+ match self
342+ .execute(Command::HistoryRepair(HistoryRepairCommand::Start {
343+ room_id,
344+ policy,
345+ budget,
346+ }))
347+ .await?
348+ {
349+ CommandOutput::HistoryRepair(report) => Ok(report),
350+ output => Err(unexpected("history repair", &output)),
351+ }
352+ }
353+
354+ pub async fn continue_history_repair(
355+ &self,
356+ room_id: OwnedRoomId,
357+ budget: HistoryRepairBudget,
358+ ) -> Result<HistoryRepairReport, Error> {
359+ match self
360+ .execute(Command::HistoryRepair(HistoryRepairCommand::Continue {
361+ room_id,
362+ budget,
363+ }))
364+ .await?
365+ {
366+ CommandOutput::HistoryRepair(report) => Ok(report),
367+ output => Err(unexpected("history repair", &output)),
368+ }
369+ }
370+
371+ pub async fn cancel_history_repair(&self, room_id: OwnedRoomId) -> Result<(), Error> {
372+ self.run(Command::HistoryRepair(HistoryRepairCommand::Cancel {
373+ room_id,
374+ }))
375+ .await
376+ }
377+
378+ pub async fn start_recent_history(
379+ &self,
380+ room_id: OwnedRoomId,
381+ ) -> Result<HistoryRepairReport, Error> {
382+ match self
383+ .execute(Command::HistoryRepair(HistoryRepairCommand::StartRecent {
384+ room_id,
385+ }))
386+ .await?
387+ {
388+ CommandOutput::HistoryRepair(report) => Ok(report),
389+ output => Err(unexpected("history repair", &output)),
390+ }
391+ }
392+
393+ pub async fn history_repair_policy(&self) -> Result<HistoryRepairPolicy, Error> {
394+ match self
395+ .execute(Command::HistoryRepair(HistoryRepairCommand::GetPolicy))
396+ .await?
397+ {
398+ CommandOutput::HistoryRepairPolicy(policy) => Ok(policy),
399+ output => Err(unexpected("history repair policy", &output)),
400+ }
401+ }
402+
403+ pub async fn set_history_repair_policy(
404+ &self,
405+ policy: HistoryRepairPolicy,
406+ ) -> Result<(), Error> {
407+ self.run(Command::HistoryRepair(HistoryRepairCommand::SetPolicy(
408+ policy,
409+ )))
410+ .await
411+ }
412+
330413 pub async fn event_context(
331414 &self,
332415 room_id: &RoomId,
@@ -1430,29 +1513,153 @@ pub(crate) struct DriverPort {
14301513 requests: mpsc::Receiver<Request>,
14311514 events: mpsc::Sender<Event>,
14321515 connection: watch::Sender<ConnectionState>,
1516+ publication: Arc<PublicationRegistry>,
1517+}
1518+
1519+#[doc(hidden)]
1520+#[derive(Clone, Debug)]
1521+pub struct PublicationAck(Arc<PublicationAckInner>);
1522+
1523+#[derive(Debug)]
1524+struct PublicationAckInner {
1525+ sender: Mutex<Option<oneshot::Sender<bool>>>,
1526+}
1527+
1528+impl PartialEq for PublicationAck {
1529+ fn eq(&self, other: &Self) -> bool {
1530+ Arc::ptr_eq(&self.0, &other.0)
1531+ }
1532+}
1533+
1534+impl Eq for PublicationAck {}
1535+
1536+impl PublicationAck {
1537+ pub(crate) fn complete(&self, published: bool) {
1538+ let sender = self
1539+ .0
1540+ .sender
1541+ .lock()
1542+ .unwrap_or_else(|error| error.into_inner())
1543+ .take();
1544+ if let Some(sender) = sender {
1545+ let _ = sender.send(published);
1546+ }
1547+ }
1548+}
1549+
1550+#[derive(Debug, Default)]
1551+struct PublicationRegistry {
1552+ state: Mutex<PublicationRegistryState>,
1553+}
1554+
1555+#[derive(Debug, Default)]
1556+struct PublicationRegistryState {
1557+ mirrors: usize,
1558+ pending: Vec<Weak<PublicationAckInner>>,
1559+}
1560+
1561+impl PublicationRegistry {
1562+ fn register_mirror(self: &Arc<Self>) -> PublicationRegistration {
1563+ self.state
1564+ .lock()
1565+ .unwrap_or_else(|error| error.into_inner())
1566+ .mirrors += 1;
1567+ PublicationRegistration {
1568+ registry: self.clone(),
1569+ }
1570+ }
1571+
1572+ fn acknowledgement(&self) -> Option<(PublicationAck, oneshot::Receiver<bool>)> {
1573+ let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner());
1574+ if state.mirrors == 0 {
1575+ return None;
1576+ }
1577+ state.pending.retain(|pending| pending.strong_count() > 0);
1578+ let (sender, receiver) = oneshot::channel();
1579+ let acknowledgement = PublicationAck(Arc::new(PublicationAckInner {
1580+ sender: Mutex::new(Some(sender)),
1581+ }));
1582+ state.pending.push(Arc::downgrade(&acknowledgement.0));
1583+ Some((acknowledgement, receiver))
1584+ }
1585+}
1586+
1587+pub(crate) struct PublicationRegistration {
1588+ registry: Arc<PublicationRegistry>,
1589+}
1590+
1591+impl Drop for PublicationRegistration {
1592+ fn drop(&mut self) {
1593+ let pending = {
1594+ let mut state = self
1595+ .registry
1596+ .state
1597+ .lock()
1598+ .unwrap_or_else(|error| error.into_inner());
1599+ state.mirrors -= 1;
1600+ if state.mirrors == 0 {
1601+ std::mem::take(&mut state.pending)
1602+ } else {
1603+ Vec::new()
1604+ }
1605+ };
1606+ for acknowledgement in pending.into_iter().filter_map(|ack| ack.upgrade()) {
1607+ PublicationAck(acknowledgement).complete(false);
1608+ }
1609+ }
14331610 }
14341611
14351612 #[derive(Clone, Debug)]
1436-pub(crate) struct EventSender(mpsc::Sender<Event>);
1613+pub(crate) struct EventSender {
1614+ events: mpsc::Sender<Event>,
1615+ publication: Arc<PublicationRegistry>,
1616+}
14371617
14381618 impl EventSender {
14391619 pub(crate) async fn send(&self, event: Event) -> Result<(), SendEventError> {
1440- self.0.send(event).await.map_err(|_| SendEventError)
1620+ self.events.send(event).await.map_err(|_| SendEventError)
1621+ }
1622+
1623+ pub(crate) async fn publish_repair(
1624+ &self,
1625+ report: crate::HistoryRepairReport,
1626+ ) -> Result<(), SendEventError> {
1627+ let Some((acknowledgement, published)) = self.publication.acknowledgement() else {
1628+ return self.send(Event::HistoryRepairChanged(report)).await;
1629+ };
1630+ if let Err(error) = self
1631+ .send(Event::HistoryRepairPublished {
1632+ report,
1633+ acknowledgement: Some(acknowledgement.clone()),
1634+ })
1635+ .await
1636+ {
1637+ acknowledgement.complete(false);
1638+ return Err(error);
1639+ }
1640+ drop(acknowledgement);
1641+ match published.await {
1642+ Ok(true) => Ok(()),
1643+ Ok(false) | Err(_) => Err(SendEventError),
1644+ }
14411645 }
14421646
14431647 pub(crate) async fn reserve(&self) -> Result<mpsc::Permit<'_, Event>, SendEventError> {
1444- self.0.reserve().await.map_err(|_| SendEventError)
1648+ self.events.reserve().await.map_err(|_| SendEventError)
14451649 }
14461650
14471651 /// resolves once every `EventStream` is gone
14481652 pub(crate) async fn closed(&self) {
1449- self.0.closed().await;
1653+ self.events.closed().await;
14501654 }
14511655 }
14521656
14531657 impl DriverPort {
14541658 pub(crate) fn event_sender(&self) -> EventSender {
1455- EventSender(self.events.clone())
1659+ EventSender {
1660+ events: self.events.clone(),
1661+ publication: self.publication.clone(),
1662+ }
14561663 }
14571664
14581665 pub(crate) fn connection_sender(&self) -> watch::Sender<ConnectionState> {
@@ -1488,6 +1695,7 @@ pub(crate) fn transport_pair(
14881695 let (request_tx, request_rx) = mpsc::channel(command_capacity);
14891696 let (event_tx, event_rx) = mpsc::channel(event_capacity);
14901697 let (connection_tx, connection_rx) = watch::channel(ConnectionState::Idle);
1698+ let publication = Arc::new(PublicationRegistry::default());
14911699 (
14921700 ClientHandle {
14931701 requests: request_tx,
@@ -1498,12 +1706,14 @@ pub(crate) fn transport_pair(
14981706 media,
14991707 spaces: Arc::new(OnceCell::new()),
15001708 members_checked: Arc::default(),
1709+ publication: publication.clone(),
15011710 _store_lock: store_lock,
15021711 },
15031712 DriverPort {
15041713 requests: request_rx,
15051714 events: event_tx,
15061715 connection: connection_tx,
1716+ publication,
15071717 },
15081718 EventStream { events: event_rx },
15091719 )
@@ -1591,3 +1801,80 @@ fn unexpected(wanted: &str, output: &CommandOutput) -> Error {
15911801 format!("expected {wanted} but got {output:?}"),
15921802 )
15931803 }
1804+
1805+#[cfg(test)]
1806+mod tests {
1807+ use super::*;
1808+ use crate::{
1809+ HistoryContinuity, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairStrategy,
1810+ };
1811+ use ruma::room_id;
1812+
1813+ fn report() -> HistoryRepairReport {
1814+ HistoryRepairReport {
1815+ room_id: room_id!("!room:example.org").to_owned(),
1816+ requested_policy: HistoryRepairPolicy::Bridge,
1817+ strategy: HistoryRepairStrategy::Bridge,
1818+ coverage_duration_ms: None,
1819+ threshold_evaluated: false,
1820+ pages: 0,
1821+ events: 0,
1822+ phase: HistoryRepairPhase::Ready,
1823+ continuity: HistoryContinuity::Verified,
1824+ recent_available: true,
1825+ stop: None,
1826+ failure: None,
1827+ }
1828+ }
1829+
1830+ #[tokio::test]
1831+ async fn publish_repair_waits_for_mirror_publication() {
1832+ let publication = Arc::new(PublicationRegistry::default());
1833+ let _registration = publication.register_mirror();
1834+ let (events, mut received) = mpsc::channel(1);
1835+ let sender = EventSender {
1836+ events,
1837+ publication,
1838+ };
1839+ let expected = report();
1840+ let expected_for_task = expected.clone();
1841+ let publisher = tokio::spawn(async move { sender.publish_repair(expected_for_task).await });
1842+
1843+ let Some(Event::HistoryRepairPublished {
1844+ report,
1845+ acknowledgement: Some(acknowledgement),
1846+ }) = received.recv().await
1847+ else {
1848+ panic!("repair event was not published");
1849+ };
1850+ assert_eq!(report, expected);
1851+ tokio::task::yield_now().await;
1852+ assert!(!publisher.is_finished());
1853+
1854+ acknowledgement.complete(true);
1855+ assert!(matches!(publisher.await.unwrap(), Ok(())));
1856+ }
1857+
1858+ #[tokio::test]
1859+ async fn publish_repair_fails_when_last_mirror_drops() {
1860+ let publication = Arc::new(PublicationRegistry::default());
1861+ let registration = publication.register_mirror();
1862+ let (events, mut received) = mpsc::channel(1);
1863+ let sender = EventSender {
1864+ events,
1865+ publication,
1866+ };
1867+ let publisher = tokio::spawn(async move { sender.publish_repair(report()).await });
1868+
1869+ assert!(matches!(
1870+ received.recv().await,
1871+ Some(Event::HistoryRepairPublished {
1872+ acknowledgement: Some(_),
1873+ ..
1874+ })
1875+ ));
1876+ drop(registration);
1877+
1878+ assert!(matches!(publisher.await.unwrap(), Err(SendEventError)));
1879+ }
1880+}
Added file: tests/history_repair.rs+611 additions
@@ -0,0 +1,611 @@
1+mod support;
2+
3+use std::time::Duration;
4+
5+use macltok::{
6+ ClientHandle, Engine, EngineConfig, EngineError, Event, EventStream, HistoryContinuity,
7+ HistoryRepairBudget, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairStop,
8+ HistoryRepairStrategy, Termination, TimelineItem,
9+};
10+#[cfg(feature = "mirror")]
11+use macltok::{Mirror, TimelineId};
12+use matrix_sdk::{
13+ Client,
14+ ruma::{EventId, RoomId, api::client::sync::sync_events::v5, event_id, room_id},
15+ test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},
16+};
17+use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
18+use support::{event_messages, receive_until, shut_down};
19+use wiremock::ResponseTemplate;
20+
21+struct TestRoom {
22+ server: MatrixMockServer,
23+ sync_client: Client,
24+ handle: ClientHandle,
25+ events: EventStream,
26+ running: tokio::task::JoinHandle<Result<Termination, EngineError>>,
27+}
28+
29+async fn open_room(room_id: &RoomId) -> TestRoom {
30+ let server = MatrixMockServer::new().await;
31+ server
32+ .mock_sliding_sync()
33+ .ok(v5::Response::new("1".to_owned()))
34+ .mount()
35+ .await;
36+ let client = server.client_builder().build().await;
37+ server.mock_room_state_encryption().plain().mount().await;
38+ server.sync_joined_room(&client, room_id).await;
39+ let sync_client = client.clone();
40+ let config = EngineConfig::default().with_initial_history_limit(0);
41+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
42+ let running = tokio::spawn(engine.run());
43+
44+ handle.open_room(room_id.to_owned()).await.unwrap();
45+ receive_until(&mut events, |event| {
46+ matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
47+ })
48+ .await;
49+
50+ TestRoom {
51+ server,
52+ sync_client,
53+ handle,
54+ events,
55+ running,
56+ }
57+}
58+
59+async fn sync_cached_event(room: &mut TestRoom, room_id: &RoomId, id: &EventId, body: &str) {
60+ room.server
61+ .sync_room(
62+ &room.sync_client,
63+ JoinedRoomBuilder::new(room_id).add_timeline_event(
64+ EventFactory::new()
65+ .text_msg(body)
66+ .event_id(id)
67+ .sender(&ALICE),
68+ ),
69+ )
70+ .await;
71+ receive_until(&mut room.events, |event| {
72+ event_messages(event)
73+ .into_iter()
74+ .any(|event| event.event_id.as_deref() == Some(id))
75+ })
76+ .await;
77+}
78+
79+fn history_event(
80+ room_id: &RoomId,
81+ id: &EventId,
82+ body: &str,
83+) -> matrix_sdk::ruma::serde::Raw<matrix_sdk::ruma::events::AnyTimelineEvent> {
84+ EventFactory::new()
85+ .room(room_id)
86+ .text_msg(body)
87+ .event_id(id)
88+ .sender(&ALICE)
89+ .into_raw_timeline()
90+}
91+
92+fn report_budget(max_pages: u16) -> HistoryRepairBudget {
93+ HistoryRepairBudget {
94+ max_pages,
95+ ..HistoryRepairBudget::default()
96+ }
97+}
98+
99+#[tokio::test]
100+async fn recent_repair_starts_without_a_cursor_and_keeps_its_end_for_pagination() {
101+ let room_id = room_id!("!recent-repair:localhost");
102+ let stale_id = event_id!("$stale:localhost");
103+ let fresh_id = event_id!("$fresh:localhost");
104+ let older_id = event_id!("$older:localhost");
105+ let mut room = open_room(room_id).await;
106+ sync_cached_event(&mut room, room_id, stale_id, "cached before repair").await;
107+
108+ room.server
109+ .mock_room_messages()
110+ .ok(RoomMessagesResponseTemplate::default()
111+ .end_token("recent-prev")
112+ .events(vec![history_event(room_id, fresh_id, "fresh page")]))
113+ .mock_once()
114+ .mount()
115+ .await;
116+ let report = room
117+ .handle
118+ .history_repair(
119+ room_id.to_owned(),
120+ Some(HistoryRepairPolicy::StartRecent),
121+ HistoryRepairBudget::default(),
122+ )
123+ .await
124+ .unwrap();
125+ assert_eq!(report.strategy, HistoryRepairStrategy::Recent);
126+ assert_eq!(report.phase, HistoryRepairPhase::Ready);
127+ assert!(report.recent_available);
128+
129+ let reset = receive_until(&mut room.events, |event| {
130+ matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
131+ && event_messages(event)
132+ .into_iter()
133+ .any(|event| event.event_id.as_deref() == Some(fresh_id))
134+ })
135+ .await;
136+ let reset_ids = event_messages(&reset)
137+ .into_iter()
138+ .filter_map(|event| event.event_id.as_ref().map(ToString::to_string))
139+ .collect::<Vec<_>>();
140+ assert_eq!(reset_ids, vec![fresh_id.to_string()]);
141+
142+ let requests = room.server.received_requests().await.unwrap();
143+ let messages = requests
144+ .iter()
145+ .filter(|request| request.url.path().ends_with("/messages"))
146+ .collect::<Vec<_>>();
147+ assert_eq!(messages.len(), 1);
148+ assert!(!messages[0].url.query_pairs().any(|(key, _)| key == "from"));
149+
150+ room.server
151+ .mock_room_messages()
152+ .match_from("recent-prev")
153+ .ok(
154+ RoomMessagesResponseTemplate::default().events(vec![history_event(
155+ room_id,
156+ older_id,
157+ "older page",
158+ )]),
159+ )
160+ .mock_once()
161+ .mount()
162+ .await;
163+ room.handle
164+ .paginate(room_id.to_owned().into(), 10)
165+ .await
166+ .unwrap();
167+ receive_until(&mut room.events, |event| {
168+ event_messages(event).into_iter().any(|event| {
169+ support::message(event).is_some_and(|message| message.body == "older page")
170+ })
171+ })
172+ .await;
173+
174+ shut_down(&room.handle, &mut room.events, room.running).await;
175+}
176+
177+#[cfg(feature = "mirror")]
178+#[tokio::test]
179+async fn repair_ack_waits_until_the_mirror_has_applied_the_reset_and_report() {
180+ let server = MatrixMockServer::new().await;
181+ server
182+ .mock_sliding_sync()
183+ .ok(v5::Response::new("1".to_owned()))
184+ .mount()
185+ .await;
186+ let client = server.client_builder().build().await;
187+ let room_id = room_id!("!repair-mirror:localhost");
188+ server.mock_room_state_encryption().plain().mount().await;
189+ server.sync_joined_room(&client, room_id).await;
190+ let sync_client = client.clone();
191+ let (engine, handle, events) = Engine::from_client(
192+ client,
193+ EngineConfig::default().with_initial_history_limit(0),
194+ )
195+ .await
196+ .unwrap();
197+ let running = tokio::spawn(engine.run());
198+ let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);
199+ let mirror_running = tokio::spawn(mirror.run());
200+
201+ handle.open_room(room_id.to_owned()).await.unwrap();
202+ let stale_id = event_id!("$mirror-stale:localhost");
203+ server
204+ .sync_room(
205+ &sync_client,
206+ JoinedRoomBuilder::new(room_id).add_timeline_event(
207+ EventFactory::new()
208+ .text_msg("cached event")
209+ .event_id(stale_id)
210+ .sender(&ALICE),
211+ ),
212+ )
213+ .await;
214+ let timeline_id = TimelineId::from(room_id.to_owned());
215+ tokio::time::timeout(Duration::from_secs(2), async {
216+ loop {
217+ let has_cached_event = mirror_handle
218+ .snapshot()
219+ .timelines
220+ .get(&timeline_id)
221+ .is_some_and(|timeline| {
222+ timeline.items.iter().any(|item| {
223+ matches!(item, TimelineItem::Event(event)
224+ if event.event_id.as_deref() == Some(stale_id))
225+ })
226+ });
227+ if has_cached_event {
228+ break;
229+ }
230+ tokio::task::yield_now().await;
231+ }
232+ })
233+ .await
234+ .expect("the cached timeline event did not reach the mirror");
235+
236+ let fresh_id = event_id!("$mirror-fresh:localhost");
237+ server
238+ .mock_room_messages()
239+ .ok(
240+ RoomMessagesResponseTemplate::default().events(vec![history_event(
241+ room_id,
242+ fresh_id,
243+ "fresh event",
244+ )]),
245+ )
246+ .mock_once()
247+ .mount()
248+ .await;
249+ let report = handle
250+ .history_repair(
251+ room_id.to_owned(),
252+ Some(HistoryRepairPolicy::StartRecent),
253+ HistoryRepairBudget::default(),
254+ )
255+ .await
256+ .unwrap();
257+
258+ let state = mirror_handle.snapshot();
259+ assert_eq!(state.repairs.get(room_id), Some(&report));
260+ let timeline = state
261+ .timelines
262+ .get(&timeline_id)
263+ .expect("the repaired timeline was not applied before acknowledgement");
264+ let ids = timeline
265+ .items
266+ .iter()
267+ .filter_map(|item| match item {
268+ TimelineItem::Event(event) => event.event_id.as_ref().map(ToString::to_string),
269+ _ => None,
270+ })
271+ .collect::<Vec<_>>();
272+ assert_eq!(ids, vec![fresh_id.to_string()]);
273+
274+ handle.shutdown().await.unwrap();
275+ assert_eq!(running.await.unwrap().unwrap(), Termination::ShutDown);
276+ mirror_running.await.unwrap();
277+}
278+
279+#[tokio::test]
280+async fn failed_recent_fetch_leaves_the_existing_timeline_in_place() {
281+ let room_id = room_id!("!failed-repair:localhost");
282+ let stale_id = event_id!("$kept:localhost");
283+ let mut room = open_room(room_id).await;
284+ sync_cached_event(&mut room, room_id, stale_id, "keep this event").await;
285+
286+ room.server
287+ .mock_room_messages()
288+ .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
289+ "errcode": "M_FORBIDDEN",
290+ "error": "history is unavailable",
291+ })))
292+ .mock_once()
293+ .mount()
294+ .await;
295+ let report = room
296+ .handle
297+ .history_repair(
298+ room_id.to_owned(),
299+ Some(HistoryRepairPolicy::StartRecent),
300+ HistoryRepairBudget::default(),
301+ )
302+ .await
303+ .unwrap();
304+ assert_eq!(report.phase, HistoryRepairPhase::Paused);
305+ assert!(report.failure.is_some());
306+
307+ let mut reset_after_failure = false;
308+ while let Ok(event) = room.events.try_recv() {
309+ reset_after_failure |= matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id);
310+ }
311+ assert!(!reset_after_failure);
312+
313+ shut_down(&room.handle, &mut room.events, room.running).await;
314+}
315+
316+#[tokio::test]
317+async fn cancelling_a_delayed_bridge_page_preserves_the_recent_stage() {
318+ let room_id = room_id!("!cancel-repair:localhost");
319+ let anchor_id = event_id!("$cancel-anchor:localhost");
320+ let recent_id = event_id!("$cancel-recent:localhost");
321+ let mut room = open_room(room_id).await;
322+ sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
323+
324+ room.server
325+ .mock_room_messages()
326+ .ok(RoomMessagesResponseTemplate::default()
327+ .end_token("cancel-prev")
328+ .events(vec![history_event(room_id, recent_id, "recent")]))
329+ .mock_once()
330+ .mount()
331+ .await;
332+ let initial = room
333+ .handle
334+ .history_repair(
335+ room_id.to_owned(),
336+ Some(HistoryRepairPolicy::Bridge),
337+ report_budget(1),
338+ )
339+ .await
340+ .unwrap();
341+ assert_eq!(initial.stop, Some(HistoryRepairStop::Budget));
342+ assert!(initial.recent_available);
343+ receive_until(&mut room.events, |event| {
344+ matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
345+ && event_messages(event)
346+ .into_iter()
347+ .any(|event| event.event_id.as_deref() == Some(recent_id))
348+ })
349+ .await;
350+
351+ room.server
352+ .mock_room_messages()
353+ .match_from("cancel-prev")
354+ .ok(RoomMessagesResponseTemplate::default()
355+ .with_delay(Duration::from_secs(5))
356+ .events(vec![history_event(room_id, anchor_id, "cached anchor")]))
357+ .mock_once()
358+ .mount()
359+ .await;
360+ let repair_handle = room.handle.clone();
361+ let repair = tokio::spawn(async move {
362+ repair_handle
363+ .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())
364+ .await
365+ });
366+ tokio::time::timeout(Duration::from_secs(5), async {
367+ loop {
368+ let requested = room
369+ .server
370+ .received_requests()
371+ .await
372+ .unwrap()
373+ .iter()
374+ .any(|request| {
375+ request.url.path().ends_with("/messages")
376+ && request
377+ .url
378+ .query_pairs()
379+ .any(|(key, value)| key == "from" && value == "cancel-prev")
380+ });
381+ if requested {
382+ break;
383+ }
384+ tokio::task::yield_now().await;
385+ }
386+ })
387+ .await
388+ .expect("bridge request did not start");
389+ room.handle
390+ .cancel_history_repair(room_id.to_owned())
391+ .await
392+ .unwrap();
393+ let report = repair.await.unwrap().unwrap();
394+ assert_eq!(report.phase, HistoryRepairPhase::Paused);
395+ assert_eq!(report.stop, Some(HistoryRepairStop::Cancelled));
396+ assert!(report.recent_available);
397+ assert_eq!(report.pages, 1);
398+
399+ let mut reset_after_cancel = false;
400+ while let Ok(event) = room.events.try_recv() {
401+ reset_after_cancel |= matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id);
402+ }
403+ assert!(!reset_after_cancel);
404+
405+ shut_down(&room.handle, &mut room.events, room.running).await;
406+}
407+
408+#[tokio::test]
409+async fn bridge_reaches_the_cached_anchor_deduplicates_overlap_and_can_continue_after_budget() {
410+ let room_id = room_id!("!bridge-repair:localhost");
411+ let anchor_id = event_id!("$anchor:localhost");
412+ let middle_id = event_id!("$middle:localhost");
413+ let recent_id = event_id!("$recent:localhost");
414+ let latest_id = event_id!("$latest:localhost");
415+ let mut room = open_room(room_id).await;
416+ sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
417+
418+ room.server
419+ .mock_room_messages()
420+ .ok(RoomMessagesResponseTemplate::default()
421+ .end_token("recent-prev")
422+ .events(vec![
423+ history_event(room_id, recent_id, "recent"),
424+ history_event(room_id, middle_id, "middle"),
425+ ]))
426+ .mock_once()
427+ .mount()
428+ .await;
429+ let initial = room
430+ .handle
431+ .history_repair(
432+ room_id.to_owned(),
433+ Some(HistoryRepairPolicy::Bridge),
434+ report_budget(1),
435+ )
436+ .await
437+ .unwrap();
438+ assert_eq!(initial.phase, HistoryRepairPhase::Paused);
439+ assert_eq!(initial.stop, Some(HistoryRepairStop::Budget));
440+ assert!(initial.recent_available);
441+
442+ wiremock::Mock::given(|request: &wiremock::Request| {
443+ request.url.path().ends_with("/messages")
444+ && !request.url.query_pairs().any(|(key, _)| key == "from")
445+ })
446+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
447+ "start": "head-start",
448+ "chunk": [
449+ history_event(room_id, latest_id, "latest"),
450+ history_event(room_id, recent_id, "recent overlap"),
451+ history_event(room_id, middle_id, "middle overlap"),
452+ ],
453+ "state": [],
454+ })))
455+ .up_to_n_times(1)
456+ .expect(1)
457+ .mount(room.server.server())
458+ .await;
459+ room.server
460+ .mock_room_messages()
461+ .match_from("recent-prev")
462+ .ok(RoomMessagesResponseTemplate::default()
463+ .end_token("older-prev")
464+ .events(vec![
465+ history_event(room_id, middle_id, "middle overlap"),
466+ history_event(room_id, anchor_id, "cached anchor overlap"),
467+ ]))
468+ .mock_once()
469+ .mount()
470+ .await;
471+
472+ let report = room
473+ .handle
474+ .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())
475+ .await
476+ .unwrap();
477+ assert_eq!(report.strategy, HistoryRepairStrategy::Bridge);
478+ assert_eq!(report.phase, HistoryRepairPhase::Ready, "{report:?}");
479+ assert_eq!(report.continuity, HistoryContinuity::Verified);
480+ assert_eq!(report.pages, 3);
481+
482+ let reset = receive_until(&mut room.events, |event| {
483+ matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
484+ && event_messages(event)
485+ .into_iter()
486+ .any(|event| event.event_id.as_deref() == Some(latest_id))
487+ })
488+ .await;
489+ let reset_ids = event_messages(&reset)
490+ .into_iter()
491+ .filter_map(|event| event.event_id.as_ref().map(ToString::to_string))
492+ .collect::<Vec<_>>();
493+ assert_eq!(
494+ reset_ids,
495+ vec![
496+ anchor_id.to_string(),
497+ middle_id.to_string(),
498+ recent_id.to_string(),
499+ latest_id.to_string(),
500+ ]
501+ );
502+
503+ shut_down(&room.handle, &mut room.events, room.running).await;
504+}
505+
506+#[tokio::test]
507+async fn a_recent_window_containing_the_cached_anchor_overlaps_under_every_policy() {
508+ let cases = [
509+ (
510+ room_id!("!policy-recent:localhost"),
511+ event_id!("$policy-recent-anchor:localhost"),
512+ HistoryRepairPolicy::StartRecent,
513+ ),
514+ (
515+ room_id!("!policy-bridge:localhost"),
516+ event_id!("$policy-bridge-anchor:localhost"),
517+ HistoryRepairPolicy::Bridge,
518+ ),
519+ (
520+ room_id!("!policy-short-gap:localhost"),
521+ event_id!("$policy-short-anchor:localhost"),
522+ HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 0 },
523+ ),
524+ ];
525+
526+ for (room_id, anchor_id, policy) in cases {
527+ let mut room = open_room(room_id).await;
528+ sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
529+ room.server
530+ .mock_room_messages()
531+ .ok(
532+ RoomMessagesResponseTemplate::default().events(vec![history_event(
533+ room_id,
534+ anchor_id,
535+ "same anchor in recent history",
536+ )]),
537+ )
538+ .mock_once()
539+ .mount()
540+ .await;
541+
542+ let report = room
543+ .handle
544+ .history_repair(
545+ room_id.to_owned(),
546+ Some(policy.clone()),
547+ HistoryRepairBudget::default(),
548+ )
549+ .await
550+ .unwrap();
551+ assert_eq!(report.requested_policy, policy);
552+ assert_eq!(report.strategy, HistoryRepairStrategy::Overlap);
553+ assert_eq!(report.continuity, HistoryContinuity::Verified);
554+ assert_eq!(report.pages, 1);
555+ assert!(report.recent_available);
556+
557+ let requests = room.server.received_requests().await.unwrap();
558+ assert_eq!(
559+ requests
560+ .iter()
561+ .filter(|request| request.url.path().ends_with("/messages"))
562+ .count(),
563+ 1
564+ );
565+ shut_down(&room.handle, &mut room.events, room.running).await;
566+ }
567+}
568+
569+#[tokio::test]
570+async fn short_gap_policy_stays_recent_when_coverage_is_unknown() {
571+ let room_id = room_id!("!unknown-coverage:localhost");
572+ let anchor_id = event_id!("$unknown-anchor:localhost");
573+ let fresh_id = event_id!("$unknown-fresh:localhost");
574+ let mut room = open_room(room_id).await;
575+ sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
576+ room.server
577+ .mock_room_messages()
578+ .ok(RoomMessagesResponseTemplate::default()
579+ .end_token("recent-prev")
580+ .events(vec![history_event(room_id, fresh_id, "recent window")]))
581+ .mock_once()
582+ .mount()
583+ .await;
584+
585+ let report = room
586+ .handle
587+ .history_repair(
588+ room_id.to_owned(),
589+ Some(HistoryRepairPolicy::BridgeShortGaps {
590+ threshold_ms: 60_000,
591+ }),
592+ HistoryRepairBudget::default(),
593+ )
594+ .await
595+ .unwrap();
596+ assert_eq!(report.coverage_duration_ms, None);
597+ assert!(!report.threshold_evaluated);
598+ assert_eq!(report.strategy, HistoryRepairStrategy::Recent);
599+ assert_eq!(report.continuity, HistoryContinuity::Unresolved);
600+ assert_eq!(report.pages, 1);
601+
602+ let requests = room.server.received_requests().await.unwrap();
603+ assert_eq!(
604+ requests
605+ .iter()
606+ .filter(|request| request.url.path().ends_with("/messages"))
607+ .count(),
608+ 1
609+ );
610+ shut_down(&room.handle, &mut room.events, room.running).await;
611+}