atagen

matrix-rust-sdk

public
Star0
Watch0

Commit 7aace883fbe0

tarball

atagen <boss@atagen.co>2026-10-24 23:45 UTC

11 files changed+483-4
sliding-sync: fence response checkpoints on durable commits

Event handlers discard returned errors and detached response processing survives caller cancellation. Durable consumers need a fallible acknowledgement before response publication and checkpoint advancement.
Modified file: crates/matrix-sdk-ui/src/room_list_service/mod.rs+27 additions−1 deletions
@@ -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+ .await
190+ }
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 = client
180202 .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");
Modified file: crates/matrix-sdk-ui/src/sync_service.rs+12 additions−1 deletions
@@ -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 self
840843 }
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+ self
849+ }
850+
842851 /// Request these state events for every room the room list service
843852 /// syncs, on top of its defaults. Use it for state the sdk doesn't know
844853 /// 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
Added file: crates/matrix-sdk-ui/tests/response_commit.rs+64 additions
@@ -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+ .await
42+ .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+ .await
55+ .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+}
Modified file: crates/matrix-sdk/Cargo.toml+4 additions
@@ -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
Modified file: crates/matrix-sdk/src/sliding_sync/builder.rs+11 additions−2 deletions
@@ -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+ self
44+ }
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,
Modified file: crates/matrix-sdk/src/sliding_sync/client.rs+6 additions
@@ -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+ response
264+ }
265+
260266 pub async fn process_and_take_response(mut self) -> Result<SyncResponse> {
261267 let mut response = self.response.take().unwrap_or_default();
262268
Added file: crates/matrix-sdk/src/sliding_sync/commit.md+20 additions
@@ -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 state
4+store lock, before response handlers and observers receive the response.
5+Room state may already be persisted. The hook must tolerate replay of the
6+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. The
14+consumer must arrange for shutdown to drop or reject outstanding store
15+acknowledgements so the hook can finish with an error. Cancelling the caller
16+alone does not cancel the hook.
17+
18+The hook belongs to one Sliding Sync instance. Separate notification and
19+encryption connections do not inherit it. Native callbacks and futures must
20+be sendable. Browser callbacks and futures may use local state.
Added file: crates/matrix-sdk/src/sliding_sync/commit.rs+62 additions
@@ -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+ formatter
34+ .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) -> Self
46+ where
47+ 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+}
Modified file: crates/matrix-sdk/src/sliding_sync/error.rs+4 additions
@@ -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. See
1115 /// [`crate::sliding_sync::Version`].
1216 #[error("Sliding sync version is missing")]
Modified file: crates/matrix-sdk/src/sliding_sync/mod.rs+13 additions
@@ -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 handlers
337+ 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
Added file: crates/matrix-sdk/tests/response_commit.rs+260 additions
@@ -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+ .timeline
153+ .events
154+ .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 = client
237+ .sliding_sync("commit-test")
238+ .unwrap()
239+ .share_pos()
240+ .with_response_commit_hook(hook)
241+ .build()
242+ .await
243+ .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+}