Commit 7aace883fbe0
tarball11 files changed+483-4
@@ -63,7 +63,9 @@ use eyeball::Subscriber;
6363
use futures_util::{Stream, StreamExt, pin_mut};6464
use matrix_sdk::{6565
Client, Error as SlidingSyncError, Room, SlidingSync, SlidingSyncList, SlidingSyncMode,66
- event_cache::EventCacheError, sliding_sync::PollTimeout, timeout::timeout,66
+ event_cache::EventCacheError,67
+ sliding_sync::{PollTimeout, ResponseCommitHook},68
+ timeout::timeout,6769
};6870
pub use room_list::*;6971
use ruma::{@@ -176,6 +178,26 @@ impl RoomListService {
176178
timeline_limit: u32,177179
extra_required_state: Vec<(StateEventType, String)>,178180
) -> Result<Self, Error> {181
+ Self::new_with_commit_hook(182
+ client,183
+ share_pos,184
+ connection_id,185
+ timeline_limit,186
+ extra_required_state,187
+ None,188
+ )189
+ .await190
+ }191
+192
+ #[doc = "The hook applies only to this room list connection, before checkpoint advancement."]193
+ pub async fn new_with_commit_hook(194
+ client: Client,195
+ share_pos: bool,196
+ connection_id: &str,197
+ timeline_limit: u32,198
+ extra_required_state: Vec<(StateEventType, String)>,199
+ response_commit_hook: Option<ResponseCommitHook>,200
+ ) -> Result<Self, Error> {179201
let mut builder = client180202
.sliding_sync(connection_id)181203
.map_err(Error::SlidingSync)?@@ -190,6 +212,10 @@ impl RoomListService {
190212
enabled: Some(true),191213
}));192214
215
+ if let Some(hook) = response_commit_hook {216
+ builder = builder.with_response_commit_hook(hook);217
+ }218
+193219
match client.enabled_thread_subscriptions().await {194220
Ok(true) => {195221
debug!("Client requested thread subscriptions extension");@@ -38,6 +38,7 @@ use matrix_sdk::{
3838
executor::{JoinHandle, spawn},3939
ruma::events::StateEventType,4040
sleep::sleep,41
+ sliding_sync::ResponseCommitHook,4142
};4243
use thiserror::Error;4344
use tokio::sync::{@@ -763,6 +764,7 @@ impl SyncService {
763764
764765
#[derive(Clone)]765766
pub struct SyncServiceBuilder {767
+ room_list_response_commit_hook: Option<ResponseCommitHook>,766768
/// SDK client.767769
client: Client,768770
@@ -800,6 +802,7 @@ pub struct SyncServiceBuilder {
800802
impl SyncServiceBuilder {801803
fn new(client: Client) -> Self {802804
Self {805
+ room_list_response_commit_hook: None,803806
client,804807
with_offline_mode: false,805808
with_share_pos: true,@@ -839,6 +842,12 @@ impl SyncServiceBuilder {
839842
self840843
}841844
845
+ #[doc = "Notification lookups and encryption sync do not inherit this room list commit hook."]846
+ pub fn with_room_list_response_commit_hook(mut self, hook: ResponseCommitHook) -> Self {847
+ self.room_list_response_commit_hook = Some(hook);848
+ self849
+ }850
+842851
/// Request these state events for every room the room list service843852
/// syncs, on top of its defaults. Use it for state the sdk doesn't know844853
/// about, so it lands in the state store like the rest.@@ -864,6 +873,7 @@ impl SyncServiceBuilder {
864873
/// as the sliding syncs are supposed to run.865874
pub async fn build(self) -> Result<SyncService, Error> {866875
let Self {876
+ room_list_response_commit_hook,867877
client,868878
with_offline_mode,869879
with_share_pos,@@ -875,12 +885,13 @@ impl SyncServiceBuilder {
875885
876886
let encryption_sync_permit = Arc::new(AsyncMutex::new(EncryptionSyncPermit::new()));877887
878
- let room_list = RoomListService::new_with_required_state(888
+ let room_list = RoomListService::new_with_commit_hook(879889
client.clone(),880890
with_share_pos,881891
&room_list_conn_id,882892
room_list_timeline_limit,883893
room_list_extra_required_state,894
+ room_list_response_commit_hook,884895
)885896
.await?;886897
@@ -0,0 +1,64 @@
1
+#![cfg(not(target_family = "wasm"))]2
+3
+use std::{4
+ sync::{5
+ Arc,6
+ atomic::{AtomicUsize, Ordering},7
+ },8
+ time::Duration,9
+};10
+11
+use futures_util::{StreamExt, pin_mut};12
+use matrix_sdk::{13
+ ruma::{api::client::sync::sync_events::v5, room_id},14
+ sliding_sync::ResponseCommitHook,15
+ test_utils::mocks::MatrixMockServer,16
+};17
+use matrix_sdk_ui::sync_service::SyncService;18
+use serde_json::json;19
+20
+#[tokio::test]21
+async fn sync_service_passes_commit_hook_only_to_its_room_list_connection() {22
+ let server = MatrixMockServer::new().await;23
+ let client = server.client_builder().build().await;24
+ let commits = Arc::new(AtomicUsize::new(0));25
+ let hook = ResponseCommitHook::new({26
+ let commits = commits.clone();27
+ move |batch| {28
+ let commits = commits.clone();29
+ async move {30
+ assert_eq!(batch.connection_id, "mentions-list");31
+ assert!(batch.raw_rooms.contains_key(room_id!("!room:localhost")));32
+ commits.fetch_add(1, Ordering::SeqCst);33
+ Err("store unavailable".to_owned())34
+ }35
+ }36
+ });37
+ let service = SyncService::builder(client.clone())38
+ .with_room_list_conn_id("mentions-list".to_owned())39
+ .with_room_list_response_commit_hook(hook)40
+ .build()41
+ .await42
+ .unwrap();43
+ let mut response = v5::Response::new("position-1".to_owned());44
+ response.rooms.insert(45
+ room_id!("!room:localhost").to_owned(),46
+ serde_json::from_value(json!({ "initial": true, "required_state": [], "timeline": [] }))47
+ .unwrap(),48
+ );49
+ server.mock_sliding_sync().ok(response).mount().await;50
+ let rooms = service.room_list_service();51
+ let updates = rooms.sync();52
+ pin_mut!(updates);53
+ let error = tokio::time::timeout(Duration::from_secs(5), updates.next())54
+ .await55
+ .unwrap()56
+ .unwrap()57
+ .unwrap_err();58
+ assert!(error.to_string().contains("store unavailable"));59
+ assert_eq!(commits.load(Ordering::SeqCst), 1);60
+61
+ let independent = client.sliding_sync("independent").unwrap().build().await.unwrap();62
+ tokio::time::timeout(Duration::from_secs(5), independent.sync_once()).await.unwrap().unwrap();63
+ assert_eq!(commits.load(Ordering::SeqCst), 1);64
+}@@ -205,5 +205,9 @@ webpki-roots = "1.0.6"
205205
name = "integration"206206
required-features = ["testing"]207207
208
+[[test]]209
+name = "response_commit"210
+required-features = ["testing", "e2e-encryption"]211
+208212
[lints]209213
workspace = true@@ -11,8 +11,8 @@ use ruma::{OwnedRoomId, api::client::sync::sync_events::v5 as http};
1111
use tokio::sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock, broadcast::channel};1212
1313
use super::{14
- Error, SlidingSync, SlidingSyncInner, SlidingSyncListBuilder, SlidingSyncPositionMarkers,15
- Version, cache::format_storage_key_prefix,14
+ Error, ResponseCommitHook, SlidingSync, SlidingSyncInner, SlidingSyncListBuilder,15
+ SlidingSyncPositionMarkers, Version, cache::format_storage_key_prefix,1616
};1717
use crate::{Client, Result};1818
@@ -22,6 +22,7 @@ use crate::{Client, Result};
2222
/// [`crate::SlidingSync::builder`].2323
#[derive(Debug, Clone)]2424
pub struct SlidingSyncBuilder {25
+ response_commit_hook: Option<ResponseCommitHook>,2526
id: String,2627
storage_key: String,2728
version: Option<Version>,@@ -36,6 +37,12 @@ pub struct SlidingSyncBuilder {
3637
}3738
3839
impl SlidingSyncBuilder {40
+ #[doc = "Fences this connection's checkpoint until durable ingestion acknowledges the response."]41
+ pub fn with_response_commit_hook(mut self, hook: ResponseCommitHook) -> Self {42
+ self.response_commit_hook = Some(hook);43
+ self44
+ }45
+3946
pub(super) fn new(id: String, client: Client) -> Result<Self, Error> {4047
if id.len() > 16 {4148
Err(Error::InvalidSlidingSyncIdentifier)@@ -44,6 +51,7 @@ impl SlidingSyncBuilder {
4451
format_storage_key_prefix(&id, client.user_id().ok_or(Error::UnauthenticatedUser)?);4552
4653
Ok(Self {54
+ response_commit_hook: None,4755
id,4856
storage_key,4957
version: None,@@ -278,6 +286,7 @@ impl SlidingSyncBuilder {
278286
let lists = AsyncRwLock::new(lists);279287
280288
Ok(SlidingSync::new(SlidingSyncInner {289
+ response_commit_hook: self.response_commit_hook,281290
id: self.id,282291
283292
client,@@ -257,6 +257,12 @@ impl SlidingSyncResponseProcessor {
257257
Ok(())258258
}259259
260
+ pub fn response(&self) -> SyncResponse {261
+ let mut response = self.response.clone().unwrap_or_default();262
+ response.to_device.extend(self.to_device_events.clone());263
+ response264
+ }265
+260266
pub async fn process_and_take_response(mut self) -> Result<SyncResponse> {261267
let mut response = self.response.take().unwrap_or_default();262268
@@ -0,0 +1,20 @@
1
+A durable consumer can fence a Sliding Sync checkpoint with this hook.2
+3
+The SDK calls the hook after processing room state and releasing the state4
+store lock, before response handlers and observers receive the response.5
+Room state may already be persisted. The hook must tolerate replay of the6
+same events after an unsuccessful commit and use stable event keys.7
+8
+Return success only after the consumer's durable transaction commits.9
+Return an error when storage fails or a store acknowledgement is dropped.10
+An error prevents position advancement and response handler publication.11
+Other room-state notifications may already have been emitted during processing.12
+13
+Response processing survives cancellation of the calling sync task. The14
+consumer must arrange for shutdown to drop or reject outstanding store15
+acknowledgements so the hook can finish with an error. Cancelling the caller16
+alone does not cancel the hook.17
+18
+The hook belongs to one Sliding Sync instance. Separate notification and19
+encryption connections do not inherit it. Native callbacks and futures must20
+be sendable. Browser callbacks and futures may use local state.@@ -0,0 +1,62 @@
1
+use std::{collections::BTreeMap, fmt, future::Future, sync::Arc};2
+3
+use matrix_sdk_base::sync::SyncResponse;4
+use matrix_sdk_common::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};5
+use ruma::{OwnedRoomId, api::client::sync::sync_events::v5 as http};6
+7
+#[doc = include_str!("commit.md")]8
+#[derive(Clone)]9
+pub struct ResponseCommitHook(Arc<CommitFn>);10
+11
+#[cfg(not(target_family = "wasm"))]12
+type CommitFn = dyn Fn(ResponseCommitBatch) -> BoxFuture<'static, Result<(), String>> + Send + Sync;13
+#[cfg(target_family = "wasm")]14
+type CommitFn = dyn Fn(ResponseCommitBatch) -> BoxFuture<'static, Result<(), String>>;15
+16
+#[doc = "The processed response and original room payloads share one checkpoint boundary."]17
+#[derive(Clone)]18
+pub struct ResponseCommitBatch {19
+ #[doc = "Identifies the Sliding Sync connection whose checkpoint is fenced."]20
+ pub connection_id: String,21
+ #[doc = "The checkpoint remains here if the commit fails."]22
+ pub previous_pos: Option<String>,23
+ #[doc = "The checkpoint becomes eligible for persistence after acknowledgement."]24
+ pub next_pos: String,25
+ #[doc = "Contains processed events before response handlers and observers receive them."]26
+ pub response: SyncResponse,27
+ #[doc = "Preserves original encrypted candidates, initial flags, and gap metadata for replay."]28
+ pub raw_rooms: BTreeMap<OwnedRoomId, http::response::Room>,29
+}30
+31
+impl fmt::Debug for ResponseCommitBatch {32
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {33
+ formatter34
+ .debug_struct("ResponseCommitBatch")35
+ .field("connection_id", &self.connection_id)36
+ .field("previous_pos", &self.previous_pos)37
+ .field("next_pos", &self.next_pos)38
+ .field("room_count", &self.raw_rooms.len())39
+ .finish_non_exhaustive()40
+ }41
+}42
+43
+impl ResponseCommitHook {44
+ #[doc = "A failed or dropped store acknowledgement must produce an error from this future."]45
+ pub fn new<F, Fut>(commit: F) -> Self46
+ where47
+ F: Fn(ResponseCommitBatch) -> Fut + SendOutsideWasm + SyncOutsideWasm + 'static,48
+ Fut: Future<Output = Result<(), String>> + SendOutsideWasm + 'static,49
+ {50
+ Self(Arc::new(move |batch| Box::pin(commit(batch))))51
+ }52
+53
+ pub(super) async fn commit(&self, batch: ResponseCommitBatch) -> crate::Result<()> {54
+ (self.0)(batch).await.map_err(|error| super::Error::ResponseCommitFailed(error).into())55
+ }56
+}57
+58
+impl fmt::Debug for ResponseCommitHook {59
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {60
+ formatter.debug_struct("ResponseCommitHook").finish_non_exhaustive()61
+ }62
+}@@ -7,6 +7,10 @@ use thiserror::Error;
77
#[derive(Error, Debug)]88
#[non_exhaustive]99
pub enum Error {10
+ #[doc = "Durable ingestion failed before checkpoint advancement."]11
+ #[error("Sliding sync response commit failed, {0}")]12
+ ResponseCommitFailed(String),13
+1014
/// Sliding sync has been configured with a missing version. See1115
/// [`crate::sliding_sync::Version`].1216
#[error("Sliding sync version is missing")]@@ -18,6 +18,7 @@
1818
mod builder;1919
mod cache;2020
mod client;21
+mod commit;2122
mod error;2223
mod list;2324
@@ -31,6 +32,7 @@ use std::{
3132
3233
use async_stream::stream;3334
pub use client::{Version, VersionBuilder};35
+pub use commit::{ResponseCommitBatch, ResponseCommitHook};3436
use futures_core::stream::Stream;3537
use matrix_sdk_base::RequestedRequiredStates;3638
#[cfg(feature = "e2e-encryption")]@@ -62,6 +64,7 @@ pub struct SlidingSync {
6264
6365
#[derive(Debug)]6466
pub(super) struct SlidingSyncInner {67
+ response_commit_hook: Option<ResponseCommitHook>,6568
/// A unique identifier for this instance of sliding sync.6669
///6770
/// Used to distinguish different connections to sliding sync.@@ -331,6 +334,16 @@ impl SlidingSync {
331334
};332335
333336
// Release the lock before calling event handlers337
+ if let Some(hook) = &self.inner.response_commit_hook {338
+ hook.commit(ResponseCommitBatch {339
+ connection_id: self.inner.id.clone(),340
+ previous_pos: position.pos.clone(),341
+ next_pos: sliding_sync_response.pos.clone(),342
+ response: response_processor.response(),343
+ raw_rooms: sliding_sync_response.rooms.clone(),344
+ })345
+ .await?;346
+ }334347
response_processor.process_and_take_response().await?335348
};336349
@@ -0,0 +1,260 @@
1
+#![cfg(not(target_family = "wasm"))]2
+3
+use std::{4
+ collections::HashSet,5
+ sync::{6
+ Arc,7
+ atomic::{AtomicUsize, Ordering},8
+ },9
+ time::Duration,10
+};11
+12
+use matrix_sdk::{13
+ Client, SlidingSync, SlidingSyncList, SlidingSyncMode,14
+ ruma::{api::client::sync::sync_events::v5, events::AnySyncTimelineEvent, room_id, serde::Raw},15
+ sliding_sync::{ResponseCommitBatch, ResponseCommitHook},16
+ test_utils::mocks::MatrixMockServer,17
+};18
+use serde_json::{Value, json};19
+use tokio::sync::{broadcast::error::TryRecvError, mpsc, oneshot};20
+21
+type PendingCommit = (ResponseCommitBatch, oneshot::Sender<Result<(), String>>);22
+23
+fn queued_hook() -> (ResponseCommitHook, mpsc::UnboundedReceiver<PendingCommit>) {24
+ let (sender, receiver) = mpsc::unbounded_channel();25
+ let hook = ResponseCommitHook::new(move |batch| {26
+ let sender = sender.clone();27
+ async move {28
+ let (acknowledge, acknowledgement) = oneshot::channel();29
+ sender.send((batch, acknowledge)).map_err(|_| "store stopped".to_owned())?;30
+ acknowledgement.await.map_err(|_| "store acknowledgement dropped".to_owned())?31
+ }32
+ });33
+ (hook, receiver)34
+}35
+36
+async fn next_commit(receiver: &mut mpsc::UnboundedReceiver<PendingCommit>) -> PendingCommit {37
+ tokio::time::timeout(Duration::from_secs(5), receiver.recv()).await.unwrap().unwrap()38
+}39
+40
+async fn fixture(hook: Option<ResponseCommitHook>) -> (MatrixMockServer, Client, SlidingSync) {41
+ let server = MatrixMockServer::new().await;42
+ let client = server.client_builder().build().await;43
+ let mut response = v5::Response::new("position-1".to_owned());44
+ response.rooms.insert(45
+ room_id!("!commit:localhost").to_owned(),46
+ serde_json::from_value(json!({47
+ "initial": true,48
+ "limited": true,49
+ "prev_batch": "history-token",50
+ "required_state": [],51
+ "timeline": [{52
+ "type": "m.room.message",53
+ "event_id": "$mention",54
+ "sender": "@sender:localhost",55
+ "origin_server_ts": 1,56
+ "content": {57
+ "msgtype": "m.text",58
+ "body": "durable mention",59
+ "m.mentions": { "user_ids": [client.user_id().unwrap()] }60
+ }61
+ }, {62
+ "type": "m.room.encrypted",63
+ "event_id": "$encrypted-candidate",64
+ "sender": "@sender:localhost",65
+ "origin_server_ts": 2,66
+ "content": {67
+ "algorithm": "m.megolm.v1.aes-sha2",68
+ "ciphertext": "unavailable",69
+ "sender_key": "unavailable",70
+ "session_id": "missing-session",71
+ "device_id": "SENDER"72
+ }73
+ }]74
+ }))75
+ .unwrap(),76
+ );77
+ server.mock_sliding_sync().ok(response).mount().await;78
+ let mut builder = client.sliding_sync("commit-test").unwrap().share_pos().add_list(79
+ SlidingSyncList::builder("rooms")80
+ .sync_mode(SlidingSyncMode::new_selective().add_range(0..=0)),81
+ );82
+ if let Some(hook) = hook {83
+ builder = builder.with_response_commit_hook(hook);84
+ }85
+ let sync = builder.build().await.unwrap();86
+ (server, client, sync)87
+}88
+89
+async fn saved_position(client: &Client) -> Option<String> {90
+ let machine = client.olm_machine_for_testing().await;91
+ let key = format!("sliding_sync_store::commit-test::{}::instance", client.user_id().unwrap());92
+ let saved = machine.as_ref().unwrap().store().get_custom_value(&key).await.unwrap()?;93
+ serde_json::from_slice::<Value>(&saved).unwrap()["pos"].as_str().map(ToOwned::to_owned)94
+}95
+96
+#[tokio::test]97
+async fn acknowledgement_fences_handlers_observers_and_checkpoint() {98
+ let (hook, mut receiver) = queued_hook();99
+ let (_server, client, sync) = fixture(Some(hook)).await;100
+ let handled = Arc::new(AtomicUsize::new(0));101
+ client.add_event_handler({102
+ let handled = handled.clone();103
+ move |_event: Raw<AnySyncTimelineEvent>| {104
+ let handled = handled.clone();105
+ async move {106
+ handled.fetch_add(1, Ordering::SeqCst);107
+ }108
+ }109
+ });110
+ let mut updates = client.subscribe_to_all_room_updates();111
+ let processing = tokio::spawn(async move { sync.sync_once().await });112
+ let (batch, acknowledgement) = next_commit(&mut receiver).await;113
+ assert_eq!(batch.connection_id, "commit-test");114
+ assert!(batch.previous_pos.is_none());115
+ assert_eq!(batch.next_pos, "position-1");116
+ let room_id = room_id!("!commit:localhost");117
+ assert_eq!(batch.response.rooms.joined[room_id].timeline.events.len(), 2);118
+ let raw = serde_json::to_value(&batch.raw_rooms[room_id]).unwrap();119
+ assert_eq!(raw["initial"], true);120
+ assert_eq!(raw["limited"], true);121
+ assert_eq!(raw["prev_batch"], "history-token");122
+ assert_eq!(raw["timeline"][1]["type"], "m.room.encrypted");123
+ assert!(saved_position(&client).await.is_none());124
+ assert_eq!(handled.load(Ordering::SeqCst), 0);125
+ assert!(matches!(updates.try_recv(), Err(TryRecvError::Empty)));126
+ acknowledgement.send(Ok(())).unwrap();127
+ processing.await.unwrap().unwrap();128
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-1"));129
+ assert_eq!(handled.load(Ordering::SeqCst), 2);130
+ assert!(updates.try_recv().is_ok());131
+}132
+133
+#[tokio::test]134
+async fn failed_commit_replays_events_after_room_state_was_saved() {135
+ let (hook, mut receiver) = queued_hook();136
+ let (_server, client, sync) = fixture(Some(hook)).await;137
+ let first_sync = sync.clone();138
+ let processing = tokio::spawn(async move { first_sync.sync_once().await });139
+ let (first, acknowledgement) = next_commit(&mut receiver).await;140
+ assert!(client.get_room(room_id!("!commit:localhost")).is_some());141
+ acknowledgement.send(Err("disk full".to_owned())).unwrap();142
+ let error = processing.await.unwrap().unwrap_err();143
+ assert!(error.to_string().contains("disk full"));144
+ assert!(saved_position(&client).await.is_none());145
+146
+ let processing = tokio::spawn(async move { sync.sync_once().await });147
+ let (replay, acknowledgement) = next_commit(&mut receiver).await;148
+ assert_eq!(first.previous_pos, replay.previous_pos);149
+ let room_id = room_id!("!commit:localhost");150
+ let keys = |batch: &ResponseCommitBatch| {151
+ batch.response.rooms.joined[room_id]152
+ .timeline153
+ .events154
+ .iter()155
+ .map(|event| (room_id.to_owned(), event.event_id().unwrap()))156
+ .collect::<HashSet<_>>()157
+ };158
+ assert_eq!(keys(&first).len(), 2);159
+ assert_eq!(keys(&first), keys(&replay));160
+ acknowledgement.send(Ok(())).unwrap();161
+ processing.await.unwrap().unwrap();162
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-1"));163
+}164
+165
+#[tokio::test]166
+async fn dropped_acknowledgement_fails_without_publishing_or_saving_position() {167
+ let (hook, mut receiver) = queued_hook();168
+ let (_server, client, sync) = fixture(Some(hook)).await;169
+ let mut updates = client.subscribe_to_all_room_updates();170
+ let processing = tokio::spawn(async move { sync.sync_once().await });171
+ let (_, acknowledgement) = next_commit(&mut receiver).await;172
+ drop(acknowledgement);173
+ assert!(processing.await.unwrap().unwrap_err().to_string().contains("acknowledgement dropped"));174
+ assert!(saved_position(&client).await.is_none());175
+ assert!(matches!(updates.try_recv(), Err(TryRecvError::Empty)));176
+}177
+178
+#[tokio::test]179
+async fn cancelled_caller_and_dropped_acknowledgement_preserve_checkpoint_for_retry() {180
+ let (hook, mut receiver) = queued_hook();181
+ let (_server, client, sync) = fixture(Some(hook)).await;182
+ let first_sync = sync.clone();183
+ let processing = tokio::spawn(async move { first_sync.sync_once().await });184
+ let (_, acknowledgement) = next_commit(&mut receiver).await;185
+ processing.abort();186
+ assert!(processing.await.unwrap_err().is_cancelled());187
+ drop(acknowledgement);188
+189
+ let processing = tokio::spawn(async move { sync.sync_once().await });190
+ let (batch, acknowledgement) = next_commit(&mut receiver).await;191
+ assert!(batch.previous_pos.is_none());192
+ assert!(saved_position(&client).await.is_none());193
+ acknowledgement.send(Ok(())).unwrap();194
+ processing.await.unwrap().unwrap();195
+}196
+197
+#[tokio::test]198
+async fn successful_acknowledgement_remains_valid_after_caller_cancellation() {199
+ let (hook, mut receiver) = queued_hook();200
+ let (_server, client, sync) = fixture(Some(hook)).await;201
+ let first_sync = sync.clone();202
+ let processing = tokio::spawn(async move { first_sync.sync_once().await });203
+ let (_, acknowledgement) = next_commit(&mut receiver).await;204
+ processing.abort();205
+ assert!(processing.await.unwrap_err().is_cancelled());206
+ acknowledgement.send(Ok(())).unwrap();207
+208
+ let processing = tokio::spawn(async move { sync.sync_once().await });209
+ let (batch, acknowledgement) = next_commit(&mut receiver).await;210
+ assert_eq!(batch.previous_pos.as_deref(), Some("position-1"));211
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-1"));212
+ acknowledgement.send(Ok(())).unwrap();213
+ processing.await.unwrap().unwrap();214
+}215
+216
+#[tokio::test]217
+async fn failed_commit_preserves_a_previous_checkpoint_across_instance_restore() {218
+ let (hook, mut receiver) = queued_hook();219
+ let (server, client, sync) = fixture(Some(hook.clone())).await;220
+ let first_sync = sync.clone();221
+ let processing = tokio::spawn(async move { first_sync.sync_once().await });222
+ let (_, acknowledgement) = next_commit(&mut receiver).await;223
+ acknowledgement.send(Ok(())).unwrap();224
+ processing.await.unwrap().unwrap();225
+226
+ server.server().reset().await;227
+ server.mock_sliding_sync().ok(v5::Response::new("position-2".to_owned())).mount().await;228
+ let processing = tokio::spawn(async move { sync.sync_once().await });229
+ let (batch, acknowledgement) = next_commit(&mut receiver).await;230
+ assert_eq!(batch.previous_pos.as_deref(), Some("position-1"));231
+ assert_eq!(batch.next_pos, "position-2");232
+ acknowledgement.send(Err("write failed".to_owned())).unwrap();233
+ assert!(processing.await.unwrap().is_err());234
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-1"));235
+236
+ let restored = client237
+ .sliding_sync("commit-test")238
+ .unwrap()239
+ .share_pos()240
+ .with_response_commit_hook(hook)241
+ .build()242
+ .await243
+ .unwrap();244
+ let processing = tokio::spawn(async move { restored.sync_once().await });245
+ let (batch, acknowledgement) = next_commit(&mut receiver).await;246
+ assert_eq!(batch.previous_pos.as_deref(), Some("position-1"));247
+ assert_eq!(batch.next_pos, "position-2");248
+ acknowledgement.send(Ok(())).unwrap();249
+ processing.await.unwrap().unwrap();250
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-2"));251
+}252
+253
+#[tokio::test]254
+async fn absent_hook_preserves_checkpoint_and_observer_behavior() {255
+ let (_server, client, sync) = fixture(None).await;256
+ let mut updates = client.subscribe_to_all_room_updates();257
+ tokio::time::timeout(Duration::from_secs(5), sync.sync_once()).await.unwrap().unwrap();258
+ assert_eq!(saved_position(&client).await.as_deref(), Some("position-1"));259
+ assert!(updates.try_recv().is_ok());260
+}