Commit 80381e06a573
tarball15 files changed+2215-66
@@ -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",@@ -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 needs5454
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
@@ -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.tasks995
+ .spawn(TaskContext::Command("history repair policy"), async move {996
+ let result = match command {997
+ HistoryRepairCommand::GetPolicy => mentions998
+ .repair_policy()999
+ .await1000
+ .map(crate::CommandOutput::HistoryRepairPolicy),1001
+ HistoryRepairCommand::SetPolicy(policy) => mentions1002
+ .set_repair_policy(policy)1003
+ .await1004
+ .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_shutdown1169
+ .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
}@@ -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 self105
+ .context106
+ .mentions107
+ .repair_coverage(self.room.room_id().to_owned())108
+ .await109
+ {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.unresolved124
+ && 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_available156
+ || stage.report.continuity != HistoryContinuity::Unresolved157
+ {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 = self193
+ .context194
+ .events195
+ .publish_repair(report.clone())196
+ .await197
+ .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()).await232
+ .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
+ None239
+ }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 = self250
+ .context251
+ .events252
+ .publish_repair(report.clone())253
+ .await254
+ .map_err(|_| Error::interrupted());255
+ running256
+ .reply257
+ .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
+ boundary292
+ && *threshold_ms > 0293
+ && 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 = cache313
+ .events()314
+ .await315
+ .map_err(cache_error)?316
+ .iter()317
+ .find_map(TimelineEvent::event_id);318
+ let _ = context319
+ .events320
+ .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
+ context339
+ .mentions340
+ .repair_resolved(room.room_id().to_owned())341
+ .await?;342
+ }343
+ guard.finish().await;344
+ let _ = context345
+ .events346
+ .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 _ = context362
+ .events363
+ .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_events378
+ || stage.events.len() + 2 * usize::from(PAGE_SIZE) > STAGING_LIMIT379
+ || 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 _ = context410
+ .events411
+ .send(Event::HistoryRepairChanged(stage.report.clone()))412
+ .await;413
+ }414
+ }415
+ if stage.report.continuity != HistoryContinuity::Unresolved416
+ && stage.report.strategy == HistoryRepairStrategy::Bridge417
+ {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
+ context430
+ .mentions431
+ .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::Paused450
+ } else {451
+ HistoryRepairPhase::Ready452
+ };453
+ stage454
+}455
+456
+fn contains_anchor(stage: &Stage) -> bool {457
+ stage.anchor.as_ref().is_some_and(|anchor| {458
+ stage459
+ .events460
+ .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) = head473
+ .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
+ true481
+}482
+483
+async fn install(installs: &mpsc::Sender<InstallRequest>, stage: &Stage) -> Result<(), Error> {484
+ let (reply, response) = oneshot::channel();485
+ installs486
+ .send(InstallRequest {487
+ events: stage.events.clone(),488
+ prev_batch: stage.cursor.clone(),489
+ reply,490
+ })491
+ .await492
+ .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
+ true600
+ ));601
+ assert!(should_bridge(&HistoryRepairPolicy::Bridge, None, false));602
+ assert!(!should_bridge(603
+ &HistoryRepairPolicy::StartRecent,604
+ Some(1),605
+ true606
+ ));607
+ }608
+}@@ -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 reset1618
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 stopped133
- 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 again136148
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 = self255
+ .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
+ .await295
+ {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 {@@ -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 someone110106
// asks, and a /members fetched any other way never reaches the timeline's items111107
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 drop161
- 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 gone168168
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
+ .await174
+ .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 drop185
+ pub(super) async fn close(mut self) {186
+ self.stop_without_close().await;169187
let _ = self170188
.context171189
.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();@@ -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,@@ -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.0110
+ .send(Request::GetRepairPolicy(reply))111
+ .await112
+ .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.0119
+ .send(Request::SetRepairPolicy(policy, reply))120
+ .await121
+ .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.0131
+ .send(Request::GetRepairCoverage(room_id, reply))132
+ .await133
+ .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.0140
+ .send(Request::RepairResolved(room_id, reply))141
+ .await142
+ .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).await275
+ }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).await286
+ }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_coverage474
+ .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).await444530
}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() => {@@ -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
+ .await193
+ .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
+ .await202
+ .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
+ handle211
+ .set_repair_policy(stored_policy.clone())212
+ .await213
+ .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
+ .await226
+ .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
+ .await250
+ .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
+ service257
+ .data258
+ .repair_coverage259
+ .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<_> = events278
+ .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
+ coverage301
+ .boundary_ms302
+ .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;@@ -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>,@@ -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| {@@ -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 it908918
/// until another `TimelineReset`909919
TimelineClosed {@@ -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::StartRecent19
+ }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
+}@@ -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 the8590
/// session was built `without_persistence`8691
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]@@ -327,6 +332,84 @@ impl ClientHandle {
327332
.await328333
}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 self342
+ .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 self360
+ .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
+ .await376
+ }377
+378
+ pub async fn start_recent_history(379
+ &self,380
+ room_id: OwnedRoomId,381
+ ) -> Result<HistoryRepairReport, Error> {382
+ match self383
+ .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 self395
+ .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
+ .await411
+ }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 = self1539
+ .01540
+ .sender1541
+ .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.state1564
+ .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 = self1595
+ .registry1596
+ .state1597
+ .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) = self1631
+ .send(Event::HistoryRepairPublished {1632
+ report,1633
+ acknowledgement: Some(acknowledgement.clone()),1634
+ })1635
+ .await1636
+ {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 gone14481652
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().await1847
+ 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
+}@@ -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
+ server32
+ .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.server61
+ .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.server109
+ .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 = room117
+ .handle118
+ .history_repair(119
+ room_id.to_owned(),120
+ Some(HistoryRepairPolicy::StartRecent),121
+ HistoryRepairBudget::default(),122
+ )123
+ .await124
+ .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 = requests144
+ .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.server151
+ .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.handle164
+ .paginate(room_id.to_owned().into(), 10)165
+ .await166
+ .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
+ server182
+ .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
+ .await196
+ .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
+ server204
+ .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_handle218
+ .snapshot()219
+ .timelines220
+ .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
+ .await234
+ .expect("the cached timeline event did not reach the mirror");235
+236
+ let fresh_id = event_id!("$mirror-fresh:localhost");237
+ server238
+ .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 = handle250
+ .history_repair(251
+ room_id.to_owned(),252
+ Some(HistoryRepairPolicy::StartRecent),253
+ HistoryRepairBudget::default(),254
+ )255
+ .await256
+ .unwrap();257
+258
+ let state = mirror_handle.snapshot();259
+ assert_eq!(state.repairs.get(room_id), Some(&report));260
+ let timeline = state261
+ .timelines262
+ .get(&timeline_id)263
+ .expect("the repaired timeline was not applied before acknowledgement");264
+ let ids = timeline265
+ .items266
+ .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.server287
+ .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 = room296
+ .handle297
+ .history_repair(298
+ room_id.to_owned(),299
+ Some(HistoryRepairPolicy::StartRecent),300
+ HistoryRepairBudget::default(),301
+ )302
+ .await303
+ .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.server325
+ .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 = room333
+ .handle334
+ .history_repair(335
+ room_id.to_owned(),336
+ Some(HistoryRepairPolicy::Bridge),337
+ report_budget(1),338
+ )339
+ .await340
+ .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.server352
+ .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_handle363
+ .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())364
+ .await365
+ });366
+ tokio::time::timeout(Duration::from_secs(5), async {367
+ loop {368
+ let requested = room369
+ .server370
+ .received_requests()371
+ .await372
+ .unwrap()373
+ .iter()374
+ .any(|request| {375
+ request.url.path().ends_with("/messages")376
+ && request377
+ .url378
+ .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
+ .await388
+ .expect("bridge request did not start");389
+ room.handle390
+ .cancel_history_repair(room_id.to_owned())391
+ .await392
+ .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.server419
+ .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 = room430
+ .handle431
+ .history_repair(432
+ room_id.to_owned(),433
+ Some(HistoryRepairPolicy::Bridge),434
+ report_budget(1),435
+ )436
+ .await437
+ .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.server460
+ .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 = room473
+ .handle474
+ .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())475
+ .await476
+ .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.server530
+ .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 = room543
+ .handle544
+ .history_repair(545
+ room_id.to_owned(),546
+ Some(policy.clone()),547
+ HistoryRepairBudget::default(),548
+ )549
+ .await550
+ .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
+ requests560
+ .iter()561
+ .filter(|request| request.url.path().ends_with("/messages"))562
+ .count(),563
+ 1564
+ );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.server577
+ .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 = room586
+ .handle587
+ .history_repair(588
+ room_id.to_owned(),589
+ Some(HistoryRepairPolicy::BridgeShortGaps {590
+ threshold_ms: 60_000,591
+ }),592
+ HistoryRepairBudget::default(),593
+ )594
+ .await595
+ .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
+ requests605
+ .iter()606
+ .filter(|request| request.url.path().ends_with("/messages"))607
+ .count(),608
+ 1609
+ );610
+ shut_down(&room.handle, &mut room.events, room.running).await;611
+}