atagen

macltok

public
Star0
Watch0

Commit d9b96952ec60

tarball

atagen <boss@atagen.co>2026-10-25 09:30 UTC

13 files changed+756-927
history: drop the dead repair api and report every edge once

Start, Continue, the policy and budget types and most report fields were validated, echoed or never emitted, so consumers branched on values that could not occur. A recent load now settles only through RecentHistoryChanged and a fill only through HistoryRepairReport, and cancel or close publish Cancelled or Idle instead of Failed. Completions no longer resync the room timeline, since the watcher drains what the flush queued before the publication barrier, and joined gaps are dropped in normalisation rather than shown as Verified. The mentions repair policy goes too, as it was stored and never read.
Modified file: docs/segmented-history.md+91 additions−176 deletions
@@ -1,198 +1,113 @@
1-# Segmented history implementation
2-
3-The durable implementation is in the macltok `segmented-history` feature
4-worktree. The combined SDK revision
5-`9b2c17fb10703d90b177b02588912a34cecfa11f` is published on its
6-`segmented-history` bookmark. It includes the reaction compatibility fix from
7-`c0a` and segmented history from `49a9`. The macltok history branch includes
8-pack fixes from `e4bb841a` and the `macltok-pack-fixes` worktree through
9-`d1313f47`. All nine SDK pins and both lockfiles use the combined SDK revision.
10-The unified macltok bookmark is named `segmented-history`.
11-
12-## Current behavior
13-
14-Opening a room loads retained history and its gap rows, then fetches a bounded
15-recent window from a fresh server position. The recent fetch is capped at four
16-pages of 50 raw events. It preserves retained sections and their older cursors.
17-If it cannot establish continuity, it installs a durable gap between the
18-sections. It stops when continuity is established or accessible history ends.
19-
20-`HistoryRepairCommand::FillGap` takes a room ID and stable gap transaction ID.
21-It fetches up to four backward pages of 50 events and advances only that gap's
22-saved cursor. Fill progress and events commit atomically. Concurrent history
23-operations for the room return `RoomBusy`. An empty page with an advancing
24-cursor keeps the gap. A repeated cursor pauses it. Accessible history ending
25-before the retained boundary marks the interval unavailable. Only reaching the
26-retained boundary joins sections. Already joined gaps are harmless to stale
27-callers.
28-
29-The cache stores gap identity, boundary evidence, cursor, and status alongside
30-the encrypted linked event chunks. SQLite supports legacy token data and the
31-new metadata. IndexedDB stores the optional encrypted metadata field. Room
32-close and restart retain sections and fill cursors. Timeline snapshots and
33-incremental updates project gaps as `TimelineItem::HistoryGap`. Gap rows retain
34-their identity while status changes. Joined markers disappear and unavailable
35-intervals remain visible.
36-
37-The recent fetch can complete while live sync continues. Cache installation
38-uses a candidate state and commits events, gap metadata, and cursor together.
39-Sync publication is serialized with gap installation. Existing loaded timelines
40-receive incremental insertions that preserve retained item identities and
41-pending sends. Loading history outside the current timeline window still needs
42-a reset.
43-
44-Each SDK room cache owns a coordinator task. Recent callers share an in-flight
45-request. Cancelling one caller preserves the operation for other subscribers,
46-and cancelling all callers stops fetching before installation. Started commits
47-finish before the coordinator accepts conflicting work. No mutex guard crosses
48-the network request. The idle task holds only a weak cache reference, so it does
49-not keep a room cache alive. The SDK UI calls `wait_for_history` as a fence,
50-then takes an atomic event and gap snapshot while replacing its cache
51-subscription. The macltok watcher is stopped before its timeline reset is sent,
52-so old diffs cannot arrive after the reset. A mirror publication acknowledgement
53-completes before the command response is released. The room actor starts recent
54-loading in the background when the room opens, so opening does not wait for the
55-network request.
56-
57-Limited sync results persist explicit gap markers in the event cache. Later sync
58-updates preserve those markers until the pagination chain reaches the retained
59-boundary. The integration uses the room's live timeline flush and watcher reset
60-path. It does not reseed the cache. Thread and pinned timelines remain open
61-through this update path.
62-
63-## Public and wire shapes
64-
65-`ClientHandle::fill_history_gap(room_id, gap_id)` is the convenience method for
66-the targeted fill. It returns `Result<(), Error>`. The underlying command is
67-`HistoryRepairCommand::FillGap { room_id, gap_id }`, sent as
68-`Command::HistoryRepair`. The older policy and staged repair command variants
69-remain available for serde compatibility, but room opening no longer consults
70-the saved policy. `Continue` is invalid because continuation must identify its
71-gap. `GetPolicy` and `SetPolicy` remain compatibility operations and do not
72-control opening.
73-
74-The inline gap lifecycle value `Fetching` is reserved and is not currently
75-emitted by timeline projection. During a targeted fill, loading is signaled by
76-a `HistoryRepairReport` with `gap_id` set and `phase` set to `Bridging`. Recent
77-loading has its own room keyed `RecentHistoryState` with
78-`Idle`, `Loading`, `Loaded`, or `Failed` values. The mirror exposes that state in
79-`recent_history`, separately from gap continuity and repair progress.
80-
81-`TimelineItem::HistoryGap` carries a `HistoryGap` with `id`,
82-`older_boundary`, `newer_boundary`, `lifecycle`, `continuity`, and `failure`.
83-Lifecycle values are `Idle`, `Fetching`, `Paused`, `Failed`, and `Unavailable`.
84-Continuity values are `Unresolved`, `Verified`, and `AccessibleStart`. The gap
85-ID is an owned transaction ID. Boundary values are optional owned event IDs.
86-Failure is an optional displayable string. `TimelineState.items` contains these
87-items in order with event rows. Mirror snapshots serialize the same item shape,
88-and incremental timeline changes carry it through the normal reset or changed
89-diff.
90-
91-Serde enums use externally tagged variants. A command payload is shaped as
92-follows, with the outer `Command` adding its `HistoryRepair` tag.
1+# segmented history
2+
3+a room's history is sections of events with durable gaps between them. the sdk
4+event cache keeps the sections, gap identity, boundaries and each gap's cursor,
5+encrypted at rest, across room close and restart. macltok shows gaps inline and
6+fills them on request.
7+
8+## opening a room
9+
10+opening loads retained history and its gaps, then starts a recent load in the
11+background, so opening never waits on the network. the recent load fetches up
12+to four pages of 50 events from a fresh server position. if it reaches the
13+newest retained event the sections join. if it doesn't, a gap is left between
14+the fresh window and what was retained.
15+
16+`Event::RecentHistoryChanged` reports it, one event per edge.
17+
18+- `Loading` as it starts
19+- `Loaded`, `Cancelled` or `Failed(message)` as it settles
20+- `Idle` when the room closes, whatever was happening
21+
22+`Failed` stays until `HistoryRepairCommand::RetryRecent` or a reopen. a failed
23+load falls back to paginating `EngineConfig::initial_history_limit` events, so
24+the room isn't left empty.
25+
26+## filling a gap
27+
28+`HistoryRepairCommand::FillGap { room_id, gap_id }` walks one gap backwards, up
29+to four pages of 50, each page committing its events and the gap's new cursor
30+together. `Event::HistoryRepairChanged` carries a `HistoryRepairReport`, once
31+with phase `Bridging` as the fill starts and once more as it settles.
32+
33+- `Ready`, continuity `Verified`. the older section was reached and the gap is gone
34+- `Paused` with a stop. `Budget` after four pages, `CursorStalled` when the
35+ server repeats a cursor, `HistoryUnavailable` (continuity `AccessibleStart`)
36+ when accessible history ends first, `Cancelled`
37+- `Paused` with a `failure`, and the gap row goes `Failed`
38+
39+a gap that is already joined or unavailable answers at once and counts no page.
40+
41+## one operation per room
42+
43+a room runs one history operation at a time. `RetryRecent` joins a recent load
44+that is already running. everything else that overlaps, a fill during a recent
45+load, a recent load during a fill, a second fill, answers `RoomBusy`.
46+
47+`HistoryRepairCommand::Cancel` stops whatever is running. its own answer is
48+`Ok`, the cancelled command answers `Interrupted`, and the published outcome is
49+`Cancelled`, never a failure. pages already committed stay.
50+
51+## the timeline
52+
53+gaps appear in `TimelineState.items` as `TimelineItem::HistoryGap`, between the
54+sections they separate. a row keeps its id while its lifecycle changes, and is
55+removed once the gap joins. joined gaps never appear.
56+
57+history lands as `TimelineChanged` diffs, not a `TimelineReset`. the sdk
58+currently replays the whole live timeline when it applies committed history,
59+so those diffs can span the timeline until it gets a non-replacing fence.
60+
61+a command's answer comes after its outcome is published, and after the mirror
62+has applied it when one is attached.
63+
64+## known holes
65+
66+a limited sync for a room with no events in memory records a plain gap instead
67+of a history gap (review 2026-10-25 S1), so an interval can still render as
68+continuous. both `prev_batch` drops in the sdk's `handle_sync` remain.
69+
70+## wire shapes
71+
72+serde enums are externally tagged.
9373
9474 ```json
95-{
96- "HistoryRepair": {
97- "FillGap": {
98- "room_id": "!room:example.org",
99- "gap_id": "opaque-gap-transaction-id"
100- }
101- }
102-}
75+{ "HistoryRepair": { "FillGap": { "room_id": "!room:example.org", "gap_id": "opaque" } } }
76+{ "HistoryRepair": { "RetryRecent": { "room_id": "!room:example.org" } } }
77+{ "HistoryRepair": { "Cancel": { "room_id": "!room:example.org" } } }
10378 ```
10479
105-A gap row in `TimelineState.items` has the following shape.
80+a gap row.
10681
10782 ```json
10883 {
10984 "HistoryGap": {
110- "id": "opaque-gap-transaction-id",
85+ "id": "opaque",
11186 "older_boundary": "$older:example.org",
11287 "newer_boundary": "$newer:example.org",
11388 "lifecycle": "Idle",
114- "continuity": "Unresolved",
11589 "failure": null
11690 }
11791 }
11892 ```
11993
120-The mirror snapshot stores recent loading separately by room ID. This fragment
121-shows the serialized `State.recent_history` field.
94+lifecycle is `Idle`, `Paused`, `Failed` or `Unavailable`. `failure` is set with
95+`Failed`.
96+
97+a settled fill.
12298
12399 ```json
124100 {
125- "recent_history": {
126- "!room:example.org": "Loading"
127- }
101+ "room_id": "!room:example.org",
102+ "gap_id": "opaque",
103+ "pages": 2,
104+ "events": 63,
105+ "phase": "Ready",
106+ "continuity": "Verified",
107+ "stop": null,
108+ "failure": null
128109 }
129110 ```
130111
131-The final history repair publication acknowledgement is internal, skipped by
132-serde, and not part of application wire data. The mirror applies the report
133-before acknowledging it, so command completion follows publication.
134-
135-## Malice integration
136-
137-Pin the published macltok `segmented-history` revision in Malice's native and
138-worker manifests and regenerate their lockfiles. The SDK dependency is already
139-pinned by macltok.
140-
141-Malice should remove the saved policy and threshold controls from room opening.
142-Render each inline gap between its neighboring event sections. A fill should
143-preserve the visible event anchor, composer draft, reply or edit state, pending
144-sends, and encryption identity. Keep event-context windows independent from
145-live room history.
146-
147-## Lost interval investigation
148-
149-The reported failure showed retained events followed by newly sent messages,
150-with an omitted interval of roughly eight to ten hours presented as continuous.
151-Older pagination continued before the retained buffer, and a page refresh did
152-not recover the missing range. A refresh can reconnect to the existing
153-SharedWorker, so it does not prove that the engine restarted.
154-
155-The earlier SDK cache path could discard a limited-sync boundary token when a
156-timeline was reported unlimited despite cached events. It could also discard a
157-token when all incoming events were duplicates. Both cases can hide a real
158-discontinuity from older pagination. Initial pagination could also finish
159-against retained history before fresh sync arrived. These are concrete failure
160-paths, but none has been confirmed as the production cause. The durable gaps
161-preserve discontinuities when observed. Startup, subscription, and sync
162-diagnostics are still needed to identify where this particular interval was
163-lost.
164-
165-## Validation commands
166-
167-Run native checks from `../macltok-history-repair` with the repository's Nix
168-toolchain.
169-
170-```sh
171-nix develop --command cargo test --all-targets
172-nix develop --command cargo test --all-targets --features sqlite
173-nix develop --command cargo clippy --all-targets -- -D warnings
174-nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings
175-```
176-
177-For the browser worker runtime, use the web devshell. The harness starts a local
178-homeserver and runs both worker targets in Firefox.
179-
180-```sh
181-nix develop .#web -c nu harness/web/run.nu
182-```
183-
184-The flake checks include native and WebAssembly compilation with IndexedDB and
185-the web worker runtime. The default native gate passed all 86 tests. The
186-aggregate `nix flake check` run stopped during the SQLite and mirror build.
187-The remaining aggregate gates were waived for this handoff and have not passed.
188-The focused native history repair and engine suites passed all 11 tests with
189-SQLite, mirror, and serde enabled against the published combined SDK revision.
190-
191-Seven focused SDK recent-history tests and seven segmented-history tests pass.
192-The SDK WebAssembly compile passes with IndexedDB enabled. SDK UI timeline
193-checks pass with 123 unit tests. Separate SDK room checks pass with seven unit
194-tests and 14 timed integration tests. Two headless Firefox tests pass for the
195-IndexedDB shared gap storage contract with plaintext and encrypted stores. The
196-full SQLite and mirror gate, aggregate WebAssembly gate, and browser worker
197-runtime gate remain unverified. Complete worker restart and encrypted room
198-recovery remain integration acceptance checks.
112+the mirror keeps `State.recent_history`, a room without an entry being idle,
113+and `State.repairs`, the last fill report per room. closing a room drops both.
Modified file: src/engine/mod.rs+3 additions−24 deletions
@@ -61,6 +61,7 @@ use crate::{
6161 pub struct EngineConfig {
6262 pub command_capacity: usize,
6363 pub event_capacity: usize,
64+ /// what a thread paginates as it opens, and a room if its recent load fails. 0 for neither
6465 pub initial_history_limit: u16,
6566 pub max_media_bytes: usize,
6667 pub max_media_sources: usize,
@@ -981,30 +982,9 @@ impl Engine {
981982 fn route_repair(&mut self, command: crate::HistoryRepairCommand, reply: Responder) {
982983 use crate::HistoryRepairCommand;
983984 let room_id = match &command {
984- HistoryRepairCommand::Start { room_id, .. }
985- | HistoryRepairCommand::Continue { room_id, .. }
986- | HistoryRepairCommand::Cancel { room_id }
987- | HistoryRepairCommand::StartRecent { room_id }
985+ HistoryRepairCommand::Cancel { room_id }
986+ | HistoryRepairCommand::RetryRecent { room_id }
988987 | HistoryRepairCommand::FillGap { room_id, .. } => room_id,
989- HistoryRepairCommand::GetPolicy | HistoryRepairCommand::SetPolicy(_) => {
990- let mentions = self.mentions.clone();
991- self.tasks
992- .spawn(TaskContext::Command("history repair policy"), async move {
993- let result = match command {
994- HistoryRepairCommand::GetPolicy => mentions
995- .repair_policy()
996- .await
997- .map(crate::CommandOutput::HistoryRepairPolicy),
998- HistoryRepairCommand::SetPolicy(policy) => mentions
999- .set_repair_policy(policy)
1000- .await
1001- .map(|_| crate::CommandOutput::Done),
1002- _ => unreachable!(),
1003- };
1004- reply.send(result);
1005- });
1006- return;
1007- }
1008988 };
1009989 match self.rooms.get(room_id) {
1010990 Some(RoomSlot::Active { sender, .. }) => {
@@ -1080,7 +1060,6 @@ impl Engine {
10801060 current_user_id: self.client.user_id().map(ToOwned::to_owned),
10811061 initial_history_limit: self.config.initial_history_limit,
10821062 supervisor,
1083- mentions: self.mentions.clone(),
10841063 },
10851064 panics,
10861065 opening: vec![reply],
Modified file: src/engine/repair.rs+189 additions−256 deletions
@@ -1,4 +1,8 @@
1-use matrix_sdk::{Room, event_cache::HistoryGapFillOutcome};
1+use matrix_sdk::{
2+ Room,
3+ event_cache::{EventCacheError, HistoryGapFillOutcome, HistoryGapStatus},
4+};
5+use ruma::{OwnedRoomId, OwnedTransactionId};
26 use tokio::sync::watch;
37
48 use super::{
@@ -6,18 +10,19 @@ use super::{
610 timeline::SessionContext,
711 };
812 use crate::{
9- CommandOutput, Error, ErrorKind, HistoryContinuity, HistoryRepairBudget, HistoryRepairCommand,
10- HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop,
11- HistoryRepairStrategy, RecentHistoryState, transport::Responder,
13+ CommandOutput, Error, ErrorKind, Event, HistoryContinuity, HistoryRepairCommand,
14+ HistoryRepairPhase, HistoryRepairReport, HistoryRepairStop, RecentHistoryState,
15+ transport::Responder,
1216 };
1317
1418 const MAX_GAP_PAGES: u32 = 4;
1519
1620 struct Running {
17- task: JoinHandle<CompletionResult>,
21+ task: JoinHandle<Finished>,
1822 cancel: watch::Sender<bool>,
1923 responders: Vec<PendingResponder>,
20- recent: bool,
24+ gap_id: Option<OwnedTransactionId>,
25+ closing: bool,
2126 }
2227
2328 struct PendingResponder {
@@ -25,14 +30,33 @@ struct PendingResponder {
2530 cancel_ack: bool,
2631 }
2732
33+pub(super) enum Outcome {
34+ Recent(RecentHistoryState),
35+ Fill(HistoryRepairReport),
36+}
37+
2838 pub(super) struct Completion {
29- pub(super) report: HistoryRepairReport,
30- pub(super) recent_state: Option<RecentHistoryState>,
39+ pub(super) outcome: Outcome,
40+ /// history went into the cache, so the room timeline has to show it before anyone hears
41+ pub(super) committed: bool,
42+ pub(super) closing: bool,
3143 pub(super) result: Result<CommandOutput, Error>,
3244 responders: Vec<PendingResponder>,
3345 }
3446
3547 impl Completion {
48+ /// the room timeline couldn't show what was committed
49+ pub(super) fn unpublished(&mut self, error: Error) {
50+ match &mut self.outcome {
51+ Outcome::Recent(state) => *state = RecentHistoryState::Failed(error.message.clone()),
52+ Outcome::Fill(report) => {
53+ report.phase = HistoryRepairPhase::Paused;
54+ report.failure = Some(error.clone());
55+ }
56+ }
57+ self.result = Err(error);
58+ }
59+
3660 pub(super) fn resolve(
3761 self,
3862 result: Result<CommandOutput, Error>,
@@ -48,10 +72,16 @@ impl Completion {
4872 }
4973 }
5074
51-struct CompletionResult {
52- report: HistoryRepairReport,
53- recent_state: Option<RecentHistoryState>,
54- result: Result<CommandOutput, Error>,
75+enum Stopped {
76+ Done,
77+ Cancelled,
78+ Failed(Error),
79+}
80+
81+struct Finished {
82+ stopped: Stopped,
83+ committed: bool,
84+ report: Option<HistoryRepairReport>,
5585 }
5686
5787 pub(super) struct RepairController {
@@ -74,8 +104,7 @@ impl RepairController {
74104 }
75105
76106 pub(super) async fn start_recent(&mut self) {
77- self.start_recent_with(HistoryRepairPolicy::default(), None, true, Vec::new())
78- .await;
107+ self.start_recent_with(None).await;
79108 }
80109
81110 pub(super) async fn execute(&mut self, command: HistoryRepairCommand, reply: Responder) {
@@ -91,56 +120,17 @@ impl RepairController {
91120 reply.done(Ok(()));
92121 }
93122 }
94- HistoryRepairCommand::Start { policy, budget, .. } => {
95- if let Err(error) = validate_budget(&budget) {
96- reply.done(Err(error));
97- return;
98- }
99- let policy = policy.unwrap_or_default();
100- self.start_recent_with(policy, Some(reply), true, Vec::new())
101- .await;
102- }
103- HistoryRepairCommand::StartRecent { .. } => {
104- self.start_recent_with(
105- HistoryRepairPolicy::default(),
106- Some(reply),
107- true,
108- Vec::new(),
109- )
110- .await;
111- }
123+ HistoryRepairCommand::RetryRecent { .. } => self.start_recent_with(Some(reply)).await,
112124 HistoryRepairCommand::FillGap { gap_id, .. } => {
113125 self.start_gap_fill(gap_id, reply).await;
114126 }
115- HistoryRepairCommand::Continue { .. } => reply.done(Err(Error::new(
116- ErrorKind::Invalid,
117- "continue requires a history gap ID, use FillGap",
118- ))),
119- HistoryRepairCommand::GetPolicy => {
120- reply.send(
121- self.context
122- .mentions
123- .repair_policy()
124- .await
125- .map(CommandOutput::HistoryRepairPolicy),
126- );
127- }
128- HistoryRepairCommand::SetPolicy(policy) => {
129- reply.done(self.context.mentions.set_repair_policy(policy).await);
130- }
131127 }
132128 }
133129
134- async fn start_recent_with(
135- &mut self,
136- policy: HistoryRepairPolicy,
137- responder: Option<Responder>,
138- publish_recent: bool,
139- mut responders: Vec<PendingResponder>,
140- ) {
130+ async fn start_recent_with(&mut self, responder: Option<Responder>) {
141131 if let Some(running) = &mut self.running {
142132 if let Some(responder) = responder {
143- if running.recent {
133+ if running.gap_id.is_none() {
144134 running.responders.push(PendingResponder {
145135 responder,
146136 cancel_ack: false,
@@ -151,55 +141,45 @@ impl RepairController {
151141 }
152142 return;
153143 }
154- if let Some(responder) = responder {
155- responders.push(PendingResponder {
156- responder,
157- cancel_ack: false,
158- });
159- }
160- if publish_recent
161- && self
162- .context
163- .events
164- .send(crate::Event::RecentHistoryChanged {
165- room_id: self.room.room_id().to_owned(),
166- state: RecentHistoryState::Loading,
167- })
168- .await
169- .is_err()
170- {
171- for responder in responders {
172- responder.responder.done(Err(Error::interrupted()));
144+ let event = Event::RecentHistoryChanged {
145+ room_id: self.room.room_id().to_owned(),
146+ state: RecentHistoryState::Loading,
147+ };
148+ if self.context.events.send(event).await.is_err() {
149+ if let Some(responder) = responder {
150+ responder.done(Err(Error::interrupted()));
173151 }
174152 return;
175153 }
176154 let (cancel, receiver) = watch::channel(false);
177155 let room = self.room.clone();
178- let room_id = self.room.room_id().to_owned();
179- let task = spawn(async move { load_recent(room, room_id, policy, receiver).await });
156+ let task = spawn(async move { load_recent(room, receiver).await });
180157 self.running = Some(Running {
181158 task,
182159 cancel,
183- responders,
184- recent: true,
160+ responders: responder
161+ .map(|responder| PendingResponder {
162+ responder,
163+ cancel_ack: false,
164+ })
165+ .into_iter()
166+ .collect(),
167+ gap_id: None,
168+ closing: false,
185169 });
186170 }
187171
188- async fn start_gap_fill(&mut self, gap_id: ruma::OwnedTransactionId, responder: Responder) {
172+ async fn start_gap_fill(&mut self, gap_id: OwnedTransactionId, responder: Responder) {
189173 if self.is_running() {
190174 responder.done(Err(room_busy()));
191175 return;
192176 }
193177 let room = self.room.clone();
194- let room_id = self.room.room_id().to_owned();
195- let mut progress = report(room_id.clone(), HistoryRepairPolicy::default());
196- progress.gap_id = Some(gap_id.clone());
197- progress.strategy = HistoryRepairStrategy::Bridge;
198- progress.phase = HistoryRepairPhase::Bridging;
178+ let progress = report(self.room.room_id().to_owned(), gap_id.clone());
199179 if self
200180 .context
201181 .events
202- .send(crate::Event::HistoryRepairChanged(progress))
182+ .send(Event::HistoryRepairChanged(progress.clone()))
203183 .await
204184 .is_err()
205185 {
@@ -207,7 +187,7 @@ impl RepairController {
207187 return;
208188 }
209189 let (cancel, receiver) = watch::channel(false);
210- let task = spawn(async move { fill_gap(room, room_id, gap_id, receiver).await });
190+ let task = spawn(async move { fill_gap(room, progress, receiver).await });
211191 self.running = Some(Running {
212192 task,
213193 cancel,
@@ -215,7 +195,8 @@ impl RepairController {
215195 responder,
216196 cancel_ack: false,
217197 }],
218- recent: false,
198+ gap_id: Some(gap_id),
199+ closing: false,
219200 });
220201 }
221202
@@ -227,222 +208,174 @@ impl RepairController {
227208
228209 pub(super) async fn shutdown(&mut self) -> Option<Completion> {
229210 let running = self.running.as_mut()?;
211+ running.closing = true;
230212 running.cancel.send_replace(true);
231213 let result = (&mut running.task).await;
232214 Some(self.finish_task(result))
233215 }
234216
235- fn finish_task(&mut self, result: Result<CompletionResult, JoinError>) -> Completion {
217+ fn finish_task(&mut self, result: Result<Finished, JoinError>) -> Completion {
236218 let running = self.running.take().expect("repair task exists");
237- let result = match result {
238- Ok(result) => result,
239- Err(_) => CompletionResult {
240- report: failed_report(
241- self.room.room_id().to_owned(),
242- HistoryRepairPolicy::default(),
243- Error::interrupted(),
244- ),
245- recent_state: running.recent.then(|| {
246- RecentHistoryState::Failed(
247- "recent history operation was interrupted".to_owned(),
248- )
249- }),
250- result: Err(Error::interrupted()),
251- },
219+ let finished = result.unwrap_or(Finished {
220+ stopped: Stopped::Failed(Error::interrupted()),
221+ committed: false,
222+ report: None,
223+ });
224+ let result = match &finished.stopped {
225+ Stopped::Done => Ok(CommandOutput::Done),
226+ Stopped::Cancelled => Err(Error::interrupted()),
227+ Stopped::Failed(error) => Err(error.clone()),
228+ };
229+ let outcome = match running.gap_id {
230+ None => Outcome::Recent(match finished.stopped {
231+ _ if running.closing => RecentHistoryState::Idle,
232+ Stopped::Done => RecentHistoryState::Loaded,
233+ Stopped::Cancelled => RecentHistoryState::Cancelled,
234+ Stopped::Failed(error) => RecentHistoryState::Failed(error.message),
235+ }),
236+ Some(gap_id) => {
237+ let mut report = finished
238+ .report
239+ .unwrap_or_else(|| report(self.room.room_id().to_owned(), gap_id));
240+ match finished.stopped {
241+ Stopped::Done => {}
242+ Stopped::Cancelled => report.stop = Some(HistoryRepairStop::Cancelled),
243+ Stopped::Failed(error) => report.failure = Some(error),
244+ }
245+ report.phase = if report.stop.is_none() && report.failure.is_none() {
246+ HistoryRepairPhase::Ready
247+ } else {
248+ HistoryRepairPhase::Paused
249+ };
250+ Outcome::Fill(report)
251+ }
252252 };
253253 Completion {
254- report: result.report,
255- recent_state: result.recent_state,
256- result: result.result,
254+ outcome,
255+ committed: finished.committed,
256+ closing: running.closing,
257+ result,
257258 responders: running.responders,
258259 }
259260 }
260261 }
261262
262-fn validate_budget(budget: &HistoryRepairBudget) -> Result<(), Error> {
263- if !(1..=100).contains(&budget.max_pages)
264- || !(50..=10_000).contains(&budget.max_events)
265- || !(1..=60_000).contains(&budget.max_duration_ms)
266- {
267- return Err(Error::new(
268- ErrorKind::Invalid,
269- "repair budget requires 1 to 100 pages, 50 to 10000 events, and 1 to 60000 milliseconds",
270- ));
271- }
272- Ok(())
273-}
274-
275-async fn load_recent(
276- room: Room,
277- room_id: ruma::OwnedRoomId,
278- policy: HistoryRepairPolicy,
279- mut cancel: watch::Receiver<bool>,
280-) -> CompletionResult {
281- let base = report(room_id.clone(), policy);
263+async fn load_recent(room: Room, mut cancel: watch::Receiver<bool>) -> Finished {
282264 let outcome = tokio::select! {
283265 biased;
284- _ = cancel.changed() => Err(Error::interrupted()),
266+ _ = cancel.changed() => None,
285267 result = async {
286- let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
287- cache.load_recent_history().await.map_err(cache_error)
288- } => result,
268+ let (cache, _handles) = room.event_cache().await?;
269+ cache.load_recent_history().await
270+ } => Some(result),
289271 };
290- match outcome {
291- Ok(outcome) => {
292- let mut report = base;
293- report.pages = outcome.pages;
294- report.events = outcome.events.min(u32::MAX as usize) as u32;
295- report.recent_available = true;
296- report.phase = HistoryRepairPhase::Ready;
297- match outcome.gap.map(|gap| gap.status) {
298- None => report.continuity = HistoryContinuity::AccessibleStart,
299- Some(matrix_sdk::event_cache::HistoryGapStatus::Joined) => {
300- report.continuity = HistoryContinuity::Verified
301- }
302- Some(matrix_sdk::event_cache::HistoryGapStatus::Unavailable) => {
303- report.continuity = HistoryContinuity::AccessibleStart;
304- report.stop = Some(HistoryRepairStop::HistoryUnavailable);
305- report.phase = HistoryRepairPhase::Paused;
306- }
307- Some(matrix_sdk::event_cache::HistoryGapStatus::Paused) => {
308- report.stop = Some(HistoryRepairStop::CursorStalled);
309- report.phase = HistoryRepairPhase::Paused;
310- }
311- Some(matrix_sdk::event_cache::HistoryGapStatus::Failed { message }) => {
312- let error = Error::new(ErrorKind::Network, message.clone());
313- report.failure = Some(error.clone());
314- report.phase = HistoryRepairPhase::Paused;
315- }
316- Some(matrix_sdk::event_cache::HistoryGapStatus::Idle) => {}
317- }
318- CompletionResult {
319- report: report.clone(),
320- recent_state: Some(RecentHistoryState::Loaded),
321- result: Ok(CommandOutput::HistoryRepair(report)),
322- }
323- }
324- Err(error) => CompletionResult {
325- report: failed_report(room_id, base.requested_policy, error.clone()),
326- recent_state: Some(RecentHistoryState::Failed(error.message.clone())),
327- result: Err(error),
328- },
272+ let stopped = match outcome {
273+ None => Stopped::Cancelled,
274+ Some(Ok(_)) => Stopped::Done,
275+ Some(Err(error)) => Stopped::Failed(cache_error(error)),
276+ };
277+ Finished {
278+ committed: matches!(stopped, Stopped::Done),
279+ stopped,
280+ report: None,
329281 }
330282 }
331283
332284 async fn fill_gap(
333285 room: Room,
334- room_id: ruma::OwnedRoomId,
335- gap_id: ruma::OwnedTransactionId,
286+ mut report: HistoryRepairReport,
336287 mut cancel: watch::Receiver<bool>,
337-) -> CompletionResult {
338- let mut report = report(room_id, HistoryRepairPolicy::default());
339- report.gap_id = Some(gap_id.clone());
340- report.strategy = HistoryRepairStrategy::Bridge;
341- let outcome = async {
288+) -> Finished {
289+ let mut committed = false;
290+ let stopped = async {
342291 let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
343- let mut last_gap = None;
344292 for _ in 0..MAX_GAP_PAGES {
345293 let next = tokio::select! {
346294 biased;
347- _ = cancel.changed() => return Err(Error::interrupted()),
348- result = cache.fill_history_gap_once(gap_id.clone()) => result.map_err(cache_error)?,
295+ _ = cancel.changed() => return Ok(Stopped::Cancelled),
296+ result = cache.fill_history_gap_once(report.gap_id.clone()) => {
297+ result.map_err(cache_error)?
298+ }
349299 };
350- match next {
300+ let (gap, events) = match next {
351301 HistoryGapFillOutcome::NotFound => {
352- return Err(Error::new(ErrorKind::EventNotFound, "history gap was not found"));
302+ return Err(Error::new(
303+ ErrorKind::EventNotFound,
304+ "history gap was not found",
305+ ));
353306 }
354307 HistoryGapFillOutcome::Busy => return Err(room_busy()),
355- HistoryGapFillOutcome::Updated { gap, events } => {
356- report.pages += 1;
357- report.events = report.events.saturating_add(events.min(u32::MAX as usize) as u32);
358- match gap.status.clone() {
359- matrix_sdk::event_cache::HistoryGapStatus::Joined => {
360- report.continuity = HistoryContinuity::Verified;
361- last_gap = Some(gap);
362- break;
363- }
364- matrix_sdk::event_cache::HistoryGapStatus::Unavailable => {
365- report.continuity = HistoryContinuity::AccessibleStart;
366- report.stop = Some(HistoryRepairStop::HistoryUnavailable);
367- last_gap = Some(gap);
368- break;
369- }
370- matrix_sdk::event_cache::HistoryGapStatus::Paused => {
371- report.stop = Some(HistoryRepairStop::CursorStalled);
372- last_gap = Some(gap);
373- break;
374- }
375- matrix_sdk::event_cache::HistoryGapStatus::Failed { message } => {
376- return Err(Error::new(ErrorKind::Network, message));
377- }
378- matrix_sdk::event_cache::HistoryGapStatus::Idle => last_gap = Some(gap),
379- }
308+ HistoryGapFillOutcome::Updated { gap, events } => (gap, events),
309+ };
310+ // the sdk answers an already joined or unavailable gap without asking the server.
311+ // an empty first page that settles the gap looks the same and goes uncounted too
312+ let settled_before = report.pages == 0
313+ && events == 0
314+ && matches!(
315+ gap.status,
316+ HistoryGapStatus::Joined | HistoryGapStatus::Unavailable
317+ );
318+ if !settled_before {
319+ report.pages += 1;
320+ committed = true;
321+ }
322+ report.events = report
323+ .events
324+ .saturating_add(u32::try_from(events).unwrap_or(u32::MAX));
325+ match gap.status {
326+ HistoryGapStatus::Joined => {
327+ report.continuity = HistoryContinuity::Verified;
328+ return Ok(Stopped::Done);
380329 }
330+ HistoryGapStatus::Unavailable => {
331+ report.continuity = HistoryContinuity::AccessibleStart;
332+ report.stop = Some(HistoryRepairStop::HistoryUnavailable);
333+ return Ok(Stopped::Done);
334+ }
335+ HistoryGapStatus::Paused => {
336+ report.stop = Some(HistoryRepairStop::CursorStalled);
337+ return Ok(Stopped::Done);
338+ }
339+ HistoryGapStatus::Failed { message } => {
340+ return Err(Error::new(ErrorKind::Network, message));
341+ }
342+ HistoryGapStatus::Idle => {}
381343 }
382344 }
383- if report.pages == MAX_GAP_PAGES
384- && report.continuity == HistoryContinuity::Unresolved
385- && report.stop.is_none()
386- {
387- report.stop = Some(HistoryRepairStop::Budget);
388- }
389- Ok::<_, Error>(last_gap)
345+ report.stop = Some(HistoryRepairStop::Budget);
346+ Ok(Stopped::Done)
390347 }
391- .await;
392- let command_result = match outcome {
393- Ok(_) => {
394- report.recent_available = true;
395- report.phase = if report.stop.is_some() {
396- HistoryRepairPhase::Paused
397- } else {
398- HistoryRepairPhase::Ready
399- };
400- Ok(CommandOutput::Done)
401- }
402- Err(error) => {
403- report.failure = Some(error.clone());
404- report.phase = HistoryRepairPhase::Paused;
405- Err(error)
406- }
407- };
408- CompletionResult {
409- report,
410- recent_state: None,
411- result: command_result,
348+ .await
349+ .unwrap_or_else(Stopped::Failed);
350+ Finished {
351+ stopped,
352+ committed,
353+ report: Some(report),
412354 }
413355 }
414356
415-fn report(room_id: ruma::OwnedRoomId, policy: HistoryRepairPolicy) -> HistoryRepairReport {
357+fn report(room_id: OwnedRoomId, gap_id: OwnedTransactionId) -> HistoryRepairReport {
416358 HistoryRepairReport {
417359 room_id,
418- gap_id: None,
419- requested_policy: policy,
420- strategy: HistoryRepairStrategy::Recent,
421- coverage_duration_ms: None,
422- threshold_evaluated: false,
360+ gap_id,
423361 pages: 0,
424362 events: 0,
425- phase: HistoryRepairPhase::FetchingRecent,
363+ phase: HistoryRepairPhase::Bridging,
426364 continuity: HistoryContinuity::Unresolved,
427- recent_available: false,
428365 stop: None,
429366 failure: None,
430367 }
431368 }
432369
433-fn failed_report(
434- room_id: ruma::OwnedRoomId,
435- policy: HistoryRepairPolicy,
436- error: Error,
437-) -> HistoryRepairReport {
438- let mut report = report(room_id, policy);
439- report.phase = HistoryRepairPhase::Paused;
440- report.failure = Some(error);
441- report
442-}
443-
444-fn cache_error(source: matrix_sdk::event_cache::EventCacheError) -> Error {
445- Error::with_source(ErrorKind::Store, "room history operation failed", source)
370+fn cache_error(source: EventCacheError) -> Error {
371+ let kind = match &source {
372+ EventCacheError::PaginationError(error) => crate::error::classify_sdk_error(error),
373+ EventCacheError::InitialPaginationError(_) => ErrorKind::Network,
374+ EventCacheError::RoomNotFound { .. } => ErrorKind::RoomUnknown,
375+ EventCacheError::ClientDropped => ErrorKind::Interrupted,
376+ _ => ErrorKind::Store,
377+ };
378+ Error::with_source(kind, "room history operation failed", source)
446379 }
447380
448381 fn room_busy() -> Error {
Modified file: src/engine/room.rs+43 additions−34 deletions
@@ -6,7 +6,7 @@ use ruma::OwnedRoomId;
66 use tokio::sync::mpsc;
77
88 use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};
9-use super::repair::{Completion, RepairController};
9+use super::repair::{Completion, Outcome, RepairController};
1010 use super::timeline::{SessionContext, TaskGuard, TimelineSession};
1111 use crate::{
1212 Error, ErrorKind, Event, HistoryRepairCommand, RecentHistoryState, TimelineCommand, TimelineId,
@@ -109,8 +109,23 @@ impl RoomActor {
109109 reply.done(Ok(()));
110110 }
111111 let close = self.serve_open(&mut timelines, &mut repair).await;
112- if let Some(completion) = repair.shutdown().await {
113- self.finish_repair(completion, &mut timelines).await;
112+ let recent_settled = match repair.shutdown().await {
113+ Some(completion) => {
114+ let recent = matches!(completion.outcome, Outcome::Recent(_));
115+ self.finish_repair(completion, &mut timelines).await;
116+ recent
117+ }
118+ None => false,
119+ };
120+ if !recent_settled {
121+ let _ = self
122+ .context
123+ .events
124+ .send(Event::RecentHistoryChanged {
125+ room_id: self.room_id.clone(),
126+ state: RecentHistoryState::Idle,
127+ })
128+ .await;
114129 }
115130 if close.is_none() {
116131 self.requests.close();
@@ -141,42 +156,36 @@ impl RoomActor {
141156 }
142157
143158 async fn finish_repair(&self, mut completion: Completion, timelines: &mut Timelines) {
144- let publication = timelines.room.publish_history().await;
145- let mut cancellation = publication.clone();
146- if let Err(error) = publication {
147- completion.report.failure = Some(error.clone());
148- completion.report.phase = crate::HistoryRepairPhase::Paused;
149- completion.result = Err(error.clone());
150- if completion.recent_state.is_some() {
151- completion.recent_state = Some(RecentHistoryState::Failed(error.message));
152- }
159+ let mut cancellation = Ok(());
160+ if completion.committed
161+ && !completion.closing
162+ && completion.result.is_ok()
163+ && let Err(error) = timelines.room.publish_history().await
164+ {
165+ completion.unpublished(error.clone());
166+ cancellation = Err(error);
153167 }
168+ let event = match &completion.outcome {
169+ Outcome::Recent(state) => Event::RecentHistoryChanged {
170+ room_id: self.room_id.clone(),
171+ state: state.clone(),
172+ },
173+ Outcome::Fill(report) => Event::HistoryRepairChanged(report.clone()),
174+ };
154175 let mut resolved = completion.result.clone();
155- if let Some(state) = completion.recent_state.clone() {
156- if self
157- .context
158- .events
159- .send(Event::RecentHistoryChanged {
160- room_id: self.room_id.clone(),
161- state,
162- })
163- .await
164- .is_err()
165- {
166- resolved = Err(Error::interrupted());
167- cancellation = Err(Error::interrupted());
168- }
169- }
170- if self
171- .context
172- .events
173- .publish_repair(completion.report.clone())
174- .await
175- .is_err()
176- {
176+ let events = &self.context.events;
177+ if events.send(event).await.is_err() || events.publish_barrier().await.is_err() {
177178 resolved = Err(Error::interrupted());
178179 cancellation = Err(Error::interrupted());
179180 }
181+ let limit = self.context.initial_history_limit;
182+ if matches!(
183+ completion.outcome,
184+ Outcome::Recent(RecentHistoryState::Failed(_))
185+ ) && limit > 0
186+ {
187+ timelines.room.paginate(limit, None);
188+ }
180189 completion.resolve(resolved, cancellation);
181190 }
182191
Modified file: src/engine/timeline.rs+57 additions−28 deletions
@@ -14,7 +14,7 @@ use matrix_sdk::{
1414 };
1515 use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};
1616 use ruma::{OwnedEventId, OwnedUserId};
17-use tokio::sync::mpsc;
17+use tokio::sync::{mpsc, oneshot};
1818
1919 use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};
2020 use crate::{
@@ -66,7 +66,6 @@ impl Drop for TaskGuard {
6666 pub(super) struct SessionContext {
6767 pub(super) events: EventSender,
6868 pub(super) media: Arc<MediaService>,
69- pub(super) mentions: crate::mentions::service::Handle,
7069 pub(super) current_user_id: Option<OwnedUserId>,
7170 pub(super) initial_history_limit: u16,
7271 pub(super) supervisor: Supervisor,
@@ -78,6 +77,7 @@ pub(super) struct TimelineSession {
7877 context: SessionContext,
7978 scope: MediaScope,
8079 watcher: JoinHandle<()>,
80+ fences: mpsc::UnboundedSender<Fence>,
8181 pagination: mpsc::Sender<PaginationRequest>,
8282 pagination_worker: JoinHandle<()>,
8383 member_fetch: JoinHandle<()>,
@@ -96,7 +96,7 @@ impl TimelineSession {
9696 ) -> Result<Self, SendEventError> {
9797 let timeline = Arc::new(timeline);
9898 let scope = MediaScope::new();
99- let watcher = start_watcher(&id, &timeline, &context, &scope).await?;
99+ let (watcher, fences) = start_watcher(&id, &timeline, &context, &scope).await?;
100100 let (pagination, pagination_worker) = start_pagination_worker(&id, &timeline, &context);
101101 // under lazy-loaded members the sdk leaves sender profiles unavailable until someone
102102 // asks, and a /members fetched any other way never reaches the timeline's items
@@ -109,6 +109,7 @@ impl TimelineSession {
109109 timeline,
110110 scope,
111111 watcher,
112+ fences,
112113 pagination,
113114 pagination_worker,
114115 member_fetch,
@@ -125,23 +126,22 @@ impl TimelineSession {
125126 pub(super) async fn resync(&mut self) -> Result<(), Error> {
126127 self.watcher.abort();
127128 let _ = (&mut self.watcher).await;
128- self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)
129- .await
130- .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;
129+ (self.watcher, self.fences) =
130+ start_watcher(&self.id, &self.timeline, &self.context, &self.scope)
131+ .await
132+ .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;
131133 Ok(())
132134 }
133135
134- pub(super) async fn publish_history(&mut self) -> Result<(), Error> {
136+ /// returns once the watcher has passed on the history the room cache committed
137+ pub(super) async fn publish_history(&self) -> Result<(), Error> {
135138 self.timeline
136139 .flush_history()
137140 .await
138141 .map_err(timeline_error)?;
139- self.resync().await?;
140- self.context
141- .events
142- .publish_barrier()
143- .await
144- .map_err(|_| Error::interrupted())
142+ let (fence, drained) = oneshot::channel();
143+ self.fences.send(fence).map_err(|_| Error::interrupted())?;
144+ drained.await.map_err(|_| Error::interrupted())
145145 }
146146
147147 pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {
@@ -404,15 +404,19 @@ impl ReplyFetcher {
404404 }
405405 }
406406
407+/// asks the watcher to pass on every diff the timeline already holds, then answers
408+type Fence = oneshot::Sender<()>;
409+
407410 async fn start_watcher(
408411 id: &TimelineId,
409412 timeline: &Arc<Timeline>,
410413 context: &SessionContext,
411414 scope: &MediaScope,
412-) -> Result<JoinHandle<()>, SendEventError> {
415+) -> Result<(JoinHandle<()>, mpsc::UnboundedSender<Fence>), SendEventError> {
413416 let (items, mut stream) = timeline.subscribe().await;
414417 let current_user_id = context.current_user_id.clone();
415- let normalised = normalise_timeline(items.iter(), current_user_id.as_deref(), scope);
418+ let (normalised, mut hidden) =
419+ normalise_timeline(items.iter(), current_user_id.as_deref(), scope);
416420 context.media.register(normalised.media).await;
417421 context
418422 .events
@@ -427,31 +431,56 @@ async fn start_watcher(
427431 fetches: TaskSet::new(),
428432 };
429433 replies.fetch(normalised.missing_replies);
434+ let (fences, mut fence_requests) = mpsc::unbounded_channel::<Fence>();
430435 let id = id.clone();
431436 let events = context.events.clone();
432437 let media = context.media.clone();
433438 let scope = scope.clone();
434- Ok(context.supervisor.spawn("timeline watcher", async move {
439+ let task = context.supervisor.spawn("timeline watcher", async move {
435440 loop {
436- let diffs = tokio::select! {
441+ let (diffs, fence) = tokio::select! {
437442 diffs = stream.next() => match diffs {
438- Some(diffs) => diffs,
443+ Some(diffs) => (vec![diffs], None),
439444 None => return,
440445 },
446+ Some(fence) = fence_requests.recv() => {
447+ let mut ready = Vec::new();
448+ while let Some(diffs) = stream.next().now_or_never() {
449+ match diffs {
450+ Some(diffs) => ready.push(diffs),
451+ None => return,
452+ }
453+ }
454+ (ready, Some(fence))
455+ }
441456 Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,
442457 };
443- let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);
444- media.register(normalised.media).await;
445- replies.fetch(normalised.missing_replies);
446- let event = Event::TimelineChanged {
447- timeline_id: id.clone(),
448- diffs: normalised.diffs,
449- };
450- if events.send(event).await.is_err() {
451- return;
458+ for diffs in diffs {
459+ let normalised = normalise_timeline_diffs(
460+ diffs,
461+ current_user_id.as_deref(),
462+ &scope,
463+ &mut hidden,
464+ );
465+ media.register(normalised.media).await;
466+ replies.fetch(normalised.missing_replies);
467+ if normalised.diffs.is_empty() {
468+ continue;
469+ }
470+ let event = Event::TimelineChanged {
471+ timeline_id: id.clone(),
472+ diffs: normalised.diffs,
473+ };
474+ if events.send(event).await.is_err() {
475+ return;
476+ }
477+ }
478+ if let Some(fence) = fence {
479+ let _ = fence.send(());
452480 }
453481 }
454- }))
482+ });
483+ Ok((task, fences))
455484 }
456485
457486 async fn run_pagination(
Modified file: src/lib.rs+2 additions−3 deletions
@@ -74,9 +74,8 @@ pub use model::{
7474 VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
7575 };
7676 pub use repair_model::{
77- HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairBudget, HistoryRepairCommand,
78- HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop,
79- HistoryRepairStrategy,
77+ HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairCommand, HistoryRepairPhase,
78+ HistoryRepairReport, HistoryRepairStop,
8079 };
8180 pub use ruma;
8281 #[doc(hidden)]
Modified file: src/mirror.rs+9 additions−14 deletions
@@ -50,7 +50,7 @@ pub struct State {
5050 pub rooms: Vector<RoomSummary>,
5151 pub image_packs: ImagePacks,
5252 pub timelines: imbl::HashMap<TimelineId, TimelineState>,
53- #[cfg_attr(feature = "serde", serde(default))]
53+ /// a room without an entry is idle. closing a room drops its entries here and in `repairs`
5454 pub recent_history: imbl::HashMap<OwnedRoomId, RecentHistoryState>,
5555 pub repairs: imbl::HashMap<OwnedRoomId, HistoryRepairReport>,
5656 pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,
@@ -375,7 +375,11 @@ impl Mirror {
375375 room_id,
376376 state: recent,
377377 } => self.publish(|state| {
378- state.recent_history.insert(room_id.clone(), recent.clone());
378+ if recent == RecentHistoryState::Idle {
379+ state.recent_history.remove(&room_id);
380+ } else {
381+ state.recent_history.insert(room_id.clone(), recent.clone());
382+ }
379383 Some(StateChange::RecentHistory {
380384 room_id,
381385 state: recent,
@@ -385,18 +389,6 @@ impl Mirror {
385389 state.repairs.insert(report.room_id.clone(), report.clone());
386390 Some(StateChange::HistoryRepair(report))
387391 }),
388- Event::HistoryRepairPublished {
389- report,
390- acknowledgement,
391- } => {
392- self.publish(|state| {
393- state.repairs.insert(report.room_id.clone(), report.clone());
394- Some(StateChange::HistoryRepair(report))
395- });
396- if let Some(acknowledgement) = acknowledgement {
397- acknowledgement.complete(true);
398- }
399- }
400392 Event::PublicationBarrier { acknowledgement } => {
401393 if let Some(acknowledgement) = acknowledgement {
402394 acknowledgement.complete(true);
@@ -406,6 +398,9 @@ impl Mirror {
406398 self.cancel_resync(&timeline_id);
407399 self.publish(|state| {
408400 state.timelines.remove(&timeline_id)?;
401+ if let TimelineId::Room(room_id) = &timeline_id {
402+ state.repairs.remove(room_id);
403+ }
409404 Some(StateChange::Timeline {
410405 timeline_id,
411406 change: TimelineChange::Closed,
Modified file: src/model.rs+3 additions−8 deletions
@@ -672,8 +672,6 @@ pub enum CommandOutput {
672672 EventContextPage(EventContextPage),
673673 MentionsPage(crate::MentionsPage),
674674 MentionContext(crate::SavedMentionContext),
675- HistoryRepair(crate::HistoryRepairReport),
676- HistoryRepairPolicy(crate::HistoryRepairPolicy),
677675 }
678676
679677 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -916,12 +914,6 @@ pub enum Event {
916914 },
917915 HistoryRepairChanged(crate::HistoryRepairReport),
918916 #[doc(hidden)]
919- HistoryRepairPublished {
920- report: crate::HistoryRepairReport,
921- #[cfg_attr(feature = "serde", serde(skip))]
922- acknowledgement: Option<crate::PublicationAck>,
923- },
924- #[doc(hidden)]
925917 PublicationBarrier {
926918 #[cfg_attr(feature = "serde", serde(skip))]
927919 acknowledgement: Option<crate::PublicationAck>,
@@ -2125,9 +2117,12 @@ pub enum HistoryState {
21252117 #[derive(Clone, Debug, Eq, PartialEq)]
21262118 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21272119 pub enum RecentHistoryState {
2120+ /// nothing running and nothing to show, which is also what closing the room leaves
21282121 Idle,
21292122 Loading,
21302123 Loaded,
2124+ Cancelled,
2125+ /// stays until a retry or the room is reopened
21312126 Failed(String),
21322127 }
21332128
Modified file: src/normalise/timeline.rs+208 additions−53 deletions
@@ -19,10 +19,9 @@ use ruma::{
1919 };
2020
2121 use crate::{
22- Change, HistoryContinuity, HistoryGap, HistoryGapLifecycle, MediaId, MembershipChange, Message,
23- MessageKind, MessageMentions, Reaction, ReplyDetails, ReplyReference, SendState, Sender,
24- ThreadSummary, TimelineContent, TimelineDiff, TimelineEvent, TimelineItem, TimelineItemId,
25- UtdCause,
22+ Change, HistoryGap, HistoryGapLifecycle, MediaId, MembershipChange, Message, MessageKind,
23+ MessageMentions, Reaction, ReplyDetails, ReplyReference, SendState, Sender, ThreadSummary,
24+ TimelineContent, TimelineDiff, TimelineEvent, TimelineItem, TimelineItemId, UtdCause,
2625 };
2726
2827 use super::link_preview::sender_previews;
@@ -53,10 +52,16 @@ struct Normaliser<'a> {
5352 }
5453
5554 impl Normaliser<'_> {
56- fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {
55+ /// `None` for rows macltok leaves out
56+ fn item(&mut self, item: &SdkTimelineItem) -> Option<TimelineItem> {
57+ if let TimelineItemKind::Virtual(VirtualTimelineItem::HistoryGap(gap)) = item.kind()
58+ && gap.status == matrix_sdk::event_cache::HistoryGapStatus::Joined
59+ {
60+ return None;
61+ }
5762 self.media
5863 .extend(media_registrations_for_item(item, self.scope));
59- match item.kind() {
64+ Some(match item.kind() {
6065 TimelineItemKind::Event(event) => {
6166 TimelineItem::Event(Box::new(self.event(item, event)))
6267 }
@@ -70,41 +75,26 @@ impl Normaliser<'_> {
7075 TimelineItem::TimelineStart
7176 }
7277 TimelineItemKind::Virtual(VirtualTimelineItem::HistoryGap(gap)) => {
73- let (lifecycle, continuity, failure) = match &gap.status {
74- matrix_sdk::event_cache::HistoryGapStatus::Idle => (
75- HistoryGapLifecycle::Idle,
76- HistoryContinuity::Unresolved,
77- None,
78- ),
79- matrix_sdk::event_cache::HistoryGapStatus::Paused => (
80- HistoryGapLifecycle::Paused,
81- HistoryContinuity::Unresolved,
82- None,
83- ),
84- matrix_sdk::event_cache::HistoryGapStatus::Failed { message } => (
85- HistoryGapLifecycle::Failed,
86- HistoryContinuity::Unresolved,
87- Some(message.clone()),
88- ),
89- matrix_sdk::event_cache::HistoryGapStatus::Unavailable => (
90- HistoryGapLifecycle::Unavailable,
91- HistoryContinuity::AccessibleStart,
92- None,
93- ),
94- matrix_sdk::event_cache::HistoryGapStatus::Joined => {
95- (HistoryGapLifecycle::Idle, HistoryContinuity::Verified, None)
78+ use matrix_sdk::event_cache::HistoryGapStatus;
79+ let (lifecycle, failure) = match &gap.status {
80+ HistoryGapStatus::Idle | HistoryGapStatus::Joined => {
81+ (HistoryGapLifecycle::Idle, None)
82+ }
83+ HistoryGapStatus::Paused => (HistoryGapLifecycle::Paused, None),
84+ HistoryGapStatus::Failed { message } => {
85+ (HistoryGapLifecycle::Failed, Some(message.clone()))
9686 }
87+ HistoryGapStatus::Unavailable => (HistoryGapLifecycle::Unavailable, None),
9788 };
9889 TimelineItem::HistoryGap(HistoryGap {
9990 id: gap.id.clone(),
10091 older_boundary: gap.older_boundary.clone(),
10192 newer_boundary: gap.newer_boundary.clone(),
10293 lifecycle,
103- continuity,
10494 failure,
10595 })
10696 }
107- }
97+ })
10898 }
10999
110100 fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {
@@ -163,25 +153,28 @@ pub(crate) fn normalise_timeline<'a>(
163153 items: impl Iterator<Item = &'a Arc<SdkTimelineItem>>,
164154 current_user_id: Option<&UserId>,
165155 scope: &MediaScope,
166-) -> NormalisedTimeline {
156+) -> (NormalisedTimeline, HiddenRows) {
167157 let mut normaliser = Normaliser {
168158 current_user_id,
169159 scope,
170160 media: Vec::new(),
171161 missing_replies: Vec::new(),
172162 };
173- let items = items.map(|item| normaliser.item(item)).collect();
174- NormalisedTimeline {
175- items,
163+ let mut hidden = HiddenRows::default();
164+ let items = hidden.reset(items.map(|item| normaliser.item(item)));
165+ let normalised = NormalisedTimeline {
166+ items: items.into_iter().collect(),
176167 media: normaliser.media,
177168 missing_replies: normaliser.missing_replies,
178- }
169+ };
170+ (normalised, hidden)
179171 }
180172
181173 pub(crate) fn normalise_timeline_diffs(
182174 diffs: Vec<VectorDiff<Arc<SdkTimelineItem>>>,
183175 current_user_id: Option<&UserId>,
184176 scope: &MediaScope,
177+ hidden: &mut HiddenRows,
185178 ) -> NormalisedTimelineDiffs {
186179 let mut normaliser = Normaliser {
187180 current_user_id,
@@ -191,23 +184,7 @@ pub(crate) fn normalise_timeline_diffs(
191184 };
192185 let diffs = diffs
193186 .into_iter()
194- .map(|diff| match diff.map(|item| normaliser.item(&item)) {
195- VectorDiff::Append { values } => TimelineDiff::Append {
196- values: values.into_iter().collect(),
197- },
198- VectorDiff::Clear => TimelineDiff::Clear,
199- VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },
200- VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },
201- VectorDiff::PopFront => TimelineDiff::PopFront,
202- VectorDiff::PopBack => TimelineDiff::PopBack,
203- VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },
204- VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },
205- VectorDiff::Remove { index } => TimelineDiff::Remove { index },
206- VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },
207- VectorDiff::Reset { values } => TimelineDiff::Reset {
208- values: values.into_iter().collect(),
209- },
210- })
187+ .filter_map(|diff| hidden.translate(diff.map(|item| normaliser.item(&item))))
211188 .collect();
212189 NormalisedTimelineDiffs {
213190 diffs,
@@ -216,6 +193,135 @@ pub(crate) fn normalise_timeline_diffs(
216193 }
217194 }
218195
196+/// which sdk rows were left out, so diff indices can step over them
197+#[derive(Default)]
198+pub(crate) struct HiddenRows {
199+ rows: imbl::Vector<bool>,
200+ hidden: usize,
201+}
202+
203+impl HiddenRows {
204+ fn visible_index(&self, index: usize) -> usize {
205+ if self.hidden == 0 {
206+ return index;
207+ }
208+ self.rows
209+ .iter()
210+ .take(index)
211+ .filter(|hidden| !**hidden)
212+ .count()
213+ }
214+
215+ fn push(&mut self, front: bool, item: &Option<TimelineItem>) {
216+ self.hidden += usize::from(item.is_none());
217+ if front {
218+ self.rows.push_front(item.is_none());
219+ } else {
220+ self.rows.push_back(item.is_none());
221+ }
222+ }
223+
224+ fn forget(&mut self, hidden: bool) -> bool {
225+ self.hidden -= usize::from(hidden);
226+ !hidden
227+ }
228+
229+ fn reset(&mut self, items: impl Iterator<Item = Option<TimelineItem>>) -> Vec<TimelineItem> {
230+ *self = Self::default();
231+ items
232+ .filter_map(|item| {
233+ self.push(false, &item);
234+ item
235+ })
236+ .collect()
237+ }
238+
239+ fn translate(&mut self, diff: VectorDiff<Option<TimelineItem>>) -> Option<TimelineDiff> {
240+ Some(match diff {
241+ VectorDiff::Append { values } => {
242+ let values: Vec<_> = values
243+ .into_iter()
244+ .filter_map(|item| {
245+ self.push(false, &item);
246+ item
247+ })
248+ .collect();
249+ if values.is_empty() {
250+ return None;
251+ }
252+ TimelineDiff::Append { values }
253+ }
254+ VectorDiff::Clear => {
255+ *self = Self::default();
256+ TimelineDiff::Clear
257+ }
258+ VectorDiff::PushFront { value } => {
259+ self.push(true, &value);
260+ TimelineDiff::PushFront { value: value? }
261+ }
262+ VectorDiff::PushBack { value } => {
263+ self.push(false, &value);
264+ TimelineDiff::PushBack { value: value? }
265+ }
266+ VectorDiff::PopFront => {
267+ let hidden = self.rows.pop_front()?;
268+ self.forget(hidden).then_some(TimelineDiff::PopFront)?
269+ }
270+ VectorDiff::PopBack => {
271+ let hidden = self.rows.pop_back()?;
272+ self.forget(hidden).then_some(TimelineDiff::PopBack)?
273+ }
274+ VectorDiff::Insert { index, value } => {
275+ let visible = self.visible_index(index);
276+ self.hidden += usize::from(value.is_none());
277+ self.rows.insert(index, value.is_none());
278+ TimelineDiff::Insert {
279+ index: visible,
280+ value: value?,
281+ }
282+ }
283+ VectorDiff::Set { index, value } => {
284+ let visible = self.visible_index(index);
285+ let was_hidden = self.rows.set(index, value.is_none());
286+ self.hidden = self.hidden + usize::from(value.is_none()) - usize::from(was_hidden);
287+ match (was_hidden, value) {
288+ (false, Some(value)) => TimelineDiff::Set {
289+ index: visible,
290+ value,
291+ },
292+ (false, None) => TimelineDiff::Remove { index: visible },
293+ (true, Some(value)) => TimelineDiff::Insert {
294+ index: visible,
295+ value,
296+ },
297+ (true, None) => return None,
298+ }
299+ }
300+ VectorDiff::Remove { index } => {
301+ let visible = self.visible_index(index);
302+ let hidden = self.rows.remove(index);
303+ self.forget(hidden)
304+ .then_some(TimelineDiff::Remove { index: visible })?
305+ }
306+ VectorDiff::Truncate { length } => {
307+ let visible = self.visible_index(length);
308+ let dropped = self
309+ .rows
310+ .iter()
311+ .skip(length)
312+ .filter(|hidden| **hidden)
313+ .count();
314+ self.hidden -= dropped;
315+ self.rows.truncate(length);
316+ TimelineDiff::Truncate { length: visible }
317+ }
318+ VectorDiff::Reset { values } => TimelineDiff::Reset {
319+ values: self.reset(values.into_iter()),
320+ },
321+ })
322+ }
323+}
324+
219325 /// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own
220326 fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {
221327 match content {
@@ -485,3 +591,52 @@ pub(super) fn formatted_body(message: &MessageType) -> Option<String> {
485591 }?;
486592 (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())
487593 }
594+
595+#[cfg(test)]
596+mod tests {
597+ use super::*;
598+
599+ fn row(n: u64) -> Option<TimelineItem> {
600+ Some(TimelineItem::DayDivider { timestamp_ms: n })
601+ }
602+
603+ #[test]
604+ fn hidden_rows_keep_diff_indices_on_the_visible_timeline() {
605+ let mut sdk: imbl::Vector<Option<TimelineItem>> = imbl::Vector::new();
606+ let mut hidden = HiddenRows::default();
607+ let mut visible: imbl::Vector<TimelineItem> = imbl::Vector::new();
608+ let diffs = [
609+ VectorDiff::Reset {
610+ values: [row(0), None, row(1)].into_iter().collect(),
611+ },
612+ VectorDiff::Insert {
613+ index: 1,
614+ value: row(2),
615+ },
616+ VectorDiff::Set {
617+ index: 2,
618+ value: row(3),
619+ },
620+ VectorDiff::Set {
621+ index: 0,
622+ value: None,
623+ },
624+ VectorDiff::PushFront { value: None },
625+ VectorDiff::Remove { index: 3 },
626+ VectorDiff::Append {
627+ values: [None, row(4)].into_iter().collect(),
628+ },
629+ VectorDiff::PopFront,
630+ VectorDiff::Truncate { length: 2 },
631+ VectorDiff::PopBack,
632+ ];
633+ for diff in diffs {
634+ diff.clone().apply(&mut sdk);
635+ if let Some(diff) = hidden.translate(diff) {
636+ diff.try_apply(&mut visible).unwrap();
637+ }
638+ let expected: imbl::Vector<_> = sdk.iter().flatten().cloned().collect();
639+ assert_eq!(visible, expected);
640+ }
641+ }
642+}
Modified file: src/repair_model.rs+9 additions−109 deletions
@@ -5,62 +5,19 @@ use serde::{Deserialize, Serialize};
55
66 use crate::Error;
77
8-#[derive(Clone, Debug, PartialEq, Eq)]
9-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10-pub enum HistoryRepairPolicy {
11- StartRecent,
12- Bridge,
13- BridgeShortGaps { threshold_ms: u64 },
14-}
15-
16-impl Default for HistoryRepairPolicy {
17- fn default() -> Self {
18- Self::StartRecent
19- }
20-}
21-
22-#[derive(Clone, Debug, PartialEq)]
23-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
24-pub struct HistoryRepairBudget {
25- pub max_pages: u16,
26- pub max_events: u32,
27- pub max_duration_ms: u64,
28-}
29-
30-impl Default for HistoryRepairBudget {
31- fn default() -> Self {
32- Self {
33- max_pages: 4,
34- max_events: 200,
35- max_duration_ms: 15_000,
36- }
37- }
38-}
39-
408 #[derive(Clone, Debug, PartialEq)]
419 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10+#[non_exhaustive]
4211 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- },
12+ /// runs the open-time recent load again, joining one already running. the outcome
13+ /// arrives as `RecentHistoryChanged`
14+ RetryRecent { room_id: OwnedRoomId },
5815 FillGap {
5916 room_id: OwnedRoomId,
6017 gap_id: OwnedTransactionId,
6118 },
62- GetPolicy,
63- SetPolicy(HistoryRepairPolicy),
19+ /// stops whichever history operation the room is running
20+ Cancel { room_id: OwnedRoomId },
6421 }
6522
6623 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -70,7 +27,6 @@ pub struct HistoryGap {
7027 pub older_boundary: Option<OwnedEventId>,
7128 pub newer_boundary: Option<OwnedEventId>,
7229 pub lifecycle: HistoryGapLifecycle,
73- pub continuity: HistoryContinuity,
7430 pub failure: Option<String>,
7531 }
7632
@@ -78,22 +34,14 @@ pub struct HistoryGap {
7834 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
7935 pub enum HistoryGapLifecycle {
8036 Idle,
81- Fetching,
8237 Paused,
8338 Failed,
39+ /// accessible history ends before the older section, so this interval stays
8440 Unavailable,
8541 }
8642
8743 #[derive(Clone, Debug, PartialEq, Eq)]
8844 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
89-pub enum HistoryRepairStrategy {
90- Recent,
91- Overlap,
92- Bridge,
93-}
94-
95-#[derive(Clone, Debug, PartialEq, Eq)]
96-#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
9745 pub enum HistoryContinuity {
9846 Unresolved,
9947 Verified,
@@ -103,7 +51,6 @@ pub enum HistoryContinuity {
10351 #[derive(Clone, Debug, PartialEq)]
10452 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10553 pub enum HistoryRepairPhase {
106- FetchingRecent,
10754 Bridging,
10855 Ready,
10956 Paused,
@@ -115,66 +62,19 @@ pub enum HistoryRepairStop {
11562 Cancelled,
11663 Budget,
11764 HistoryUnavailable,
118- NoAnchor,
11965 CursorStalled,
12066 }
12167
68+/// one gap fill. `Bridging` when it starts, then once more when it settles
12269 #[derive(Clone, Debug, PartialEq)]
12370 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12471 pub struct HistoryRepairReport {
12572 pub room_id: OwnedRoomId,
126- #[cfg_attr(feature = "serde", serde(default))]
127- pub gap_id: Option<OwnedTransactionId>,
128- pub requested_policy: HistoryRepairPolicy,
129- pub strategy: HistoryRepairStrategy,
130- pub coverage_duration_ms: Option<u64>,
131- pub threshold_evaluated: bool,
73+ pub gap_id: OwnedTransactionId,
13274 pub pages: u32,
13375 pub events: u32,
13476 pub phase: HistoryRepairPhase,
13577 pub continuity: HistoryContinuity,
136- pub recent_available: bool,
13778 pub stop: Option<HistoryRepairStop>,
13879 pub failure: Option<Error>,
13980 }
140-
141-#[cfg(all(test, feature = "serde"))]
142-mod tests {
143- use super::*;
144- use crate::ErrorKind;
145- use ruma::room_id;
146-
147- #[test]
148- fn history_repair_types_round_trip_through_serde() {
149- let command = HistoryRepairCommand::Start {
150- room_id: room_id!("!room:example.org").to_owned(),
151- policy: Some(HistoryRepairPolicy::BridgeShortGaps {
152- threshold_ms: 86_400_000,
153- }),
154- budget: HistoryRepairBudget::default(),
155- };
156- let encoded_command = serde_json::to_value(&command).unwrap();
157- let decoded_command: HistoryRepairCommand =
158- serde_json::from_value(encoded_command).unwrap();
159- assert_eq!(decoded_command, command);
160-
161- let report = HistoryRepairReport {
162- gap_id: None,
163- room_id: room_id!("!room:example.org").to_owned(),
164- requested_policy: HistoryRepairPolicy::Bridge,
165- strategy: HistoryRepairStrategy::Overlap,
166- coverage_duration_ms: Some(86_400_000),
167- threshold_evaluated: false,
168- pages: 3,
169- events: 42,
170- phase: HistoryRepairPhase::Paused,
171- continuity: HistoryContinuity::Unresolved,
172- recent_available: true,
173- stop: Some(HistoryRepairStop::Budget),
174- failure: Some(Error::new(ErrorKind::Timeline, "repair failed")),
175- };
176- let encoded_report = serde_json::to_value(&report).unwrap();
177- let decoded_report: HistoryRepairReport = serde_json::from_value(encoded_report).unwrap();
178- assert_eq!(decoded_report, report);
179- }
180-}
Modified file: src/transport.rs+20 additions−135 deletions
@@ -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, 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,
32+ EventContextPage, EventRef, HistoryRepairCommand, 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,
4141 encryption::encryption_state,
4242 engine::{MediaService, StoreLock},
4343 model::{PushNotificationRoom, PushNotificationSender},
@@ -332,42 +332,6 @@ impl ClientHandle {
332332 .await
333333 }
334334
335- pub async fn history_repair(
336- &self,
337- room_id: OwnedRoomId,
338- policy: Option<HistoryRepairPolicy>,
339- budget: HistoryRepairBudget,
340- ) -> Result<HistoryRepairReport, Error> {
341- match self
342- .execute(Command::HistoryRepair(HistoryRepairCommand::Start {
343- room_id,
344- policy,
345- budget,
346- }))
347- .await?
348- {
349- CommandOutput::HistoryRepair(report) => Ok(report),
350- output => Err(unexpected("history repair", &output)),
351- }
352- }
353-
354- pub async fn continue_history_repair(
355- &self,
356- room_id: OwnedRoomId,
357- budget: HistoryRepairBudget,
358- ) -> Result<HistoryRepairReport, Error> {
359- match self
360- .execute(Command::HistoryRepair(HistoryRepairCommand::Continue {
361- room_id,
362- budget,
363- }))
364- .await?
365- {
366- CommandOutput::HistoryRepair(report) => Ok(report),
367- output => Err(unexpected("history repair", &output)),
368- }
369- }
370-
371335 pub async fn cancel_history_repair(&self, room_id: OwnedRoomId) -> Result<(), Error> {
372336 self.run(Command::HistoryRepair(HistoryRepairCommand::Cancel {
373337 room_id,
@@ -387,38 +351,10 @@ impl ClientHandle {
387351 .await
388352 }
389353
390- pub async fn start_recent_history(
391- &self,
392- room_id: OwnedRoomId,
393- ) -> Result<HistoryRepairReport, Error> {
394- match self
395- .execute(Command::HistoryRepair(HistoryRepairCommand::StartRecent {
396- room_id,
397- }))
398- .await?
399- {
400- CommandOutput::HistoryRepair(report) => Ok(report),
401- output => Err(unexpected("history repair", &output)),
402- }
403- }
404-
405- pub async fn history_repair_policy(&self) -> Result<HistoryRepairPolicy, Error> {
406- match self
407- .execute(Command::HistoryRepair(HistoryRepairCommand::GetPolicy))
408- .await?
409- {
410- CommandOutput::HistoryRepairPolicy(policy) => Ok(policy),
411- output => Err(unexpected("history repair policy", &output)),
412- }
413- }
414-
415- pub async fn set_history_repair_policy(
416- &self,
417- policy: HistoryRepairPolicy,
418- ) -> Result<(), Error> {
419- self.run(Command::HistoryRepair(HistoryRepairCommand::SetPolicy(
420- policy,
421- )))
354+ pub async fn retry_recent_history(&self, room_id: OwnedRoomId) -> Result<(), Error> {
355+ self.run(Command::HistoryRepair(HistoryRepairCommand::RetryRecent {
356+ room_id,
357+ }))
422358 .await
423359 }
424360
@@ -1689,30 +1625,6 @@ impl EventSender {
16891625 }
16901626 }
16911627
1692- pub(crate) async fn publish_repair(
1693- &self,
1694- report: crate::HistoryRepairReport,
1695- ) -> Result<(), SendEventError> {
1696- let Some((acknowledgement, published)) = self.publication.acknowledgement() else {
1697- return self.send(Event::HistoryRepairChanged(report)).await;
1698- };
1699- if let Err(error) = self
1700- .send(Event::HistoryRepairPublished {
1701- report,
1702- acknowledgement: Some(acknowledgement.clone()),
1703- })
1704- .await
1705- {
1706- acknowledgement.complete(false);
1707- return Err(error);
1708- }
1709- drop(acknowledgement);
1710- match published.await {
1711- Ok(true) => Ok(()),
1712- Ok(false) | Err(_) => Err(SendEventError),
1713- }
1714- }
1715-
17161628 pub(crate) async fn reserve(&self) -> Result<mpsc::Permit<'_, Event>, SendEventError> {
17171629 self.events.reserve().await.map_err(|_| SendEventError)
17181630 }
@@ -1874,31 +1786,9 @@ fn unexpected(wanted: &str, output: &CommandOutput) -> Error {
18741786 #[cfg(test)]
18751787 mod tests {
18761788 use super::*;
1877- use crate::{
1878- HistoryContinuity, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairStrategy,
1879- };
1880- use ruma::room_id;
1881-
1882- fn report() -> HistoryRepairReport {
1883- HistoryRepairReport {
1884- gap_id: None,
1885- room_id: room_id!("!room:example.org").to_owned(),
1886- requested_policy: HistoryRepairPolicy::Bridge,
1887- strategy: HistoryRepairStrategy::Bridge,
1888- coverage_duration_ms: None,
1889- threshold_evaluated: false,
1890- pages: 0,
1891- events: 0,
1892- phase: HistoryRepairPhase::Ready,
1893- continuity: HistoryContinuity::Verified,
1894- recent_available: true,
1895- stop: None,
1896- failure: None,
1897- }
1898- }
18991789
19001790 #[tokio::test]
1901- async fn publish_repair_waits_for_mirror_publication() {
1791+ async fn publish_barrier_waits_for_mirror_publication() {
19021792 let publication = Arc::new(PublicationRegistry::default());
19031793 let _registration = publication.register_mirror();
19041794 let (events, mut received) = mpsc::channel(1);
@@ -1906,18 +1796,14 @@ mod tests {
19061796 events,
19071797 publication,
19081798 };
1909- let expected = report();
1910- let expected_for_task = expected.clone();
1911- let publisher = tokio::spawn(async move { sender.publish_repair(expected_for_task).await });
1799+ let publisher = tokio::spawn(async move { sender.publish_barrier().await });
19121800
1913- let Some(Event::HistoryRepairPublished {
1914- report,
1801+ let Some(Event::PublicationBarrier {
19151802 acknowledgement: Some(acknowledgement),
19161803 }) = received.recv().await
19171804 else {
1918- panic!("repair event was not published");
1805+ panic!("barrier was not published");
19191806 };
1920- assert_eq!(report, expected);
19211807 tokio::task::yield_now().await;
19221808 assert!(!publisher.is_finished());
19231809
@@ -1926,7 +1812,7 @@ mod tests {
19261812 }
19271813
19281814 #[tokio::test]
1929- async fn publish_repair_fails_when_last_mirror_drops() {
1815+ async fn publish_barrier_fails_when_last_mirror_drops() {
19301816 let publication = Arc::new(PublicationRegistry::default());
19311817 let registration = publication.register_mirror();
19321818 let (events, mut received) = mpsc::channel(1);
@@ -1934,13 +1820,12 @@ mod tests {
19341820 events,
19351821 publication,
19361822 };
1937- let publisher = tokio::spawn(async move { sender.publish_repair(report()).await });
1823+ let publisher = tokio::spawn(async move { sender.publish_barrier().await });
19381824
19391825 assert!(matches!(
19401826 received.recv().await,
1941- Some(Event::HistoryRepairPublished {
1827+ Some(Event::PublicationBarrier {
19421828 acknowledgement: Some(_),
1943- ..
19441829 })
19451830 ));
19461831 drop(registration);
Modified file: tests/engine.rs+2 additions−12 deletions
@@ -4,8 +4,7 @@ use std::time::Duration;
44
55 use macltok::{
66 ClientHandle, Command, Engine, EngineConfig, ErrorKind, Event, EventStream, HistoryState,
7- MediaSize, MessageContent, RecentHistoryState, RoomCommand, TimelineCommand, TimelineId,
8- TimelineItem,
7+ MediaSize, MessageContent, RoomCommand, TimelineCommand, TimelineId, TimelineItem,
98 };
109 use matrix_sdk::{
1110 ruma::{api::client::sync::sync_events::v5, event_id, mxc_uri, room_id},
@@ -318,7 +317,6 @@ async fn closes_and_reopens_a_room_in_command_order() {
318317
319318 // biased select drains buffered events before it reports ready replies
320319 let mut resets = 0;
321- let mut recent_active = false;
322320 let mut order = Vec::new();
323321 let (mut close_done, mut replacement_open_done, mut second_close_done, mut final_open_done) =
324322 (false, false, false, false);
@@ -328,15 +326,7 @@ async fn closes_and_reopens_a_room_in_command_order() {
328326 biased;
329327 event = events.recv() => {
330328 match event.expect("event stream closed") {
331- Event::RecentHistoryChanged { room_id: changed, state } if changed == room_id => {
332- recent_active = matches!(state, RecentHistoryState::Loading);
333- }
334- Event::TimelineClosed { timeline_id } if timeline_id.room_id() == room_id => {
335- recent_active = false;
336- }
337- Event::TimelineReset { timeline_id, .. }
338- if timeline_id.room_id() == room_id && !recent_active =>
339- {
329+ Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id => {
340330 resets += 1;
341331 assert!(resets <= 2, "a room generation reset more than once");
342332 }
Modified file: tests/history_repair.rs+120 additions−75 deletions
@@ -2,19 +2,20 @@ mod support;
22
33 use std::time::Duration;
44
5+#[cfg(feature = "mirror")]
6+use macltok::Mirror;
57 use macltok::{
68 ClientHandle, Engine, EngineConfig, EngineError, Event, EventStream, HistoryGap,
7- HistoryRepairPhase, HistoryRepairPolicy, RecentHistoryState, Termination, TimelineItem,
9+ HistoryRepairPhase, HistoryRepairReport, HistoryRepairStop, RecentHistoryState, Termination,
10+ TimelineId, TimelineItem,
811 };
9-#[cfg(feature = "mirror")]
10-use macltok::{Mirror, TimelineId};
1112 use matrix_sdk::{
1213 Client,
1314 ruma::{EventId, RoomId, api::client::sync::sync_events::v5, event_id, room_id},
1415 test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},
1516 };
1617 use matrix_sdk_test::{ALICE, JoinedRoomBuilder, event_factory::EventFactory};
17-use support::{event_messages, receive_until, shut_down};
18+use support::shut_down;
1819 use wiremock::ResponseTemplate;
1920
2021 struct TestRoom {
@@ -23,6 +24,9 @@ struct TestRoom {
2324 handle: ClientHandle,
2425 events: EventStream,
2526 running: tokio::task::JoinHandle<Result<Termination, EngineError>>,
27+ /// the open room's timeline, kept current by every helper that reads events
28+ items: imbl::Vector<TimelineItem>,
29+ opened: bool,
2630 }
2731
2832 async fn start_room(room_id: &RoomId) -> TestRoom {
@@ -45,12 +49,62 @@ async fn start_room(room_id: &RoomId) -> TestRoom {
4549 handle,
4650 events,
4751 running,
52+ items: imbl::Vector::new(),
53+ opened: false,
4854 }
4955 }
5056
57+/// the next event that satisfies `predicate`. only the open itself may reset the room
58+/// timeline, history after it has to arrive as diffs
59+async fn next_until(room: &mut TestRoom, mut predicate: impl FnMut(&Event) -> bool) -> Event {
60+ tokio::time::timeout(Duration::from_secs(5), async {
61+ loop {
62+ let event = room.events.recv().await.expect("event stream closed");
63+ match &event {
64+ Event::TimelineReset {
65+ timeline_id: TimelineId::Room(_),
66+ items,
67+ } => {
68+ assert!(!room.opened, "history reset the room timeline");
69+ room.opened = true;
70+ room.items = items.clone();
71+ }
72+ Event::TimelineChanged {
73+ timeline_id: TimelineId::Room(_),
74+ diffs,
75+ } => {
76+ for diff in diffs.clone() {
77+ diff.try_apply(&mut room.items)
78+ .expect("diff did not fit the timeline");
79+ }
80+ }
81+ _ => {}
82+ }
83+ if predicate(&event) {
84+ return event;
85+ }
86+ }
87+ })
88+ .await
89+ .expect("expected event did not arrive")
90+}
91+
92+async fn timeline_until(
93+ room: &mut TestRoom,
94+ predicate: impl Fn(&imbl::Vector<TimelineItem>) -> bool,
95+) {
96+ tokio::time::timeout(Duration::from_secs(5), async {
97+ while !predicate(&room.items) {
98+ next_until(room, |_| true).await;
99+ }
100+ })
101+ .await
102+ .expect("the timeline never got there");
103+}
104+
51105 async fn open_room(room: &mut TestRoom, room_id: &RoomId) {
52106 room.handle.open_room(room_id.to_owned()).await.unwrap();
53- receive_until(&mut room.events, |event| {
107+ next_until(room, |event| {
54108 matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
55109 })
56110 .await;
@@ -84,7 +138,7 @@ fn history_event(
84138 }
85139
86140 async fn wait_for_recent(room: &mut TestRoom, room_id: &RoomId) -> RecentHistoryState {
87- let event = receive_until(&mut room.events, |event| {
141+ let event = next_until(room, |event| {
88142 matches!(event, Event::RecentHistoryChanged { room_id: id, state, .. }
89143 if id == room_id && !matches!(state, RecentHistoryState::Loading))
90144 })
@@ -95,11 +149,11 @@ async fn wait_for_recent(room: &mut TestRoom, room_id: &RoomId) -> RecentHistory
95149 }
96150 }
97151
98-async fn wait_for_report(room: &mut TestRoom, room_id: &RoomId) -> macltok::HistoryRepairReport {
99- match receive_until(
100- &mut room.events,
101- |event| matches!(event, Event::HistoryRepairChanged(report) if &report.room_id == room_id),
102- )
152+async fn wait_for_settled_fill(room: &mut TestRoom) -> HistoryRepairReport {
153+ match next_until(room, |event| {
154+ matches!(event, Event::HistoryRepairChanged(report)
155+ if report.phase != HistoryRepairPhase::Bridging)
156+ })
103157 .await
104158 {
105159 Event::HistoryRepairChanged(report) => report,
@@ -107,6 +161,12 @@ async fn wait_for_report(room: &mut TestRoom, room_id: &RoomId) -> macltok::Hist
107161 }
108162 }
109163
164+fn has_event(items: &imbl::Vector<TimelineItem>, id: &EventId) -> bool {
165+ items.iter().any(
166+ |item| matches!(item, TimelineItem::Event(event) if event.event_id.as_deref() == Some(id)),
167+ )
168+}
169+
110170 fn gap_in(items: &imbl::Vector<TimelineItem>) -> Option<HistoryGap> {
111171 items.iter().find_map(|item| match item {
112172 TimelineItem::HistoryGap(gap) => Some(gap.clone()),
@@ -125,10 +185,6 @@ async fn automatic_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap(
125185 event_id!("$fresh-d:localhost"),
126186 ];
127187 let mut room = start_room(room_id).await;
128- room.handle
129- .set_history_repair_policy(HistoryRepairPolicy::Bridge)
130- .await
131- .unwrap();
132188 for (index, id) in new_ids.iter().enumerate() {
133189 let token = format!("recent-{index}");
134190 let response = RoomMessagesResponseTemplate::default()
@@ -154,40 +210,16 @@ async fn automatic_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap(
154210 }
155211 cache_event_before_open(&room, room_id, old_id, "retained old event").await;
156212 open_room(&mut room, room_id).await;
157- let reset = receive_until(&mut room.events, |event| {
158- let items = match event {
159- Event::TimelineReset { timeline_id, items } if timeline_id.room_id() == room_id => {
160- items
161- }
162- _ => return false,
163- };
213+ timeline_until(&mut room, |items| {
164214 gap_in(items).is_some()
165- && items.iter().any(|item| {
166- matches!(item, TimelineItem::Event(event)
167- if event.event_id.as_deref() == Some(old_id))
168- })
169- && new_ids.iter().all(|id| {
170- items.iter().any(|item| {
171- matches!(item,
172- TimelineItem::Event(event) if event.event_id.as_deref() == Some(id))
173- })
174- })
215+ && has_event(items, old_id)
216+ && new_ids.iter().all(|id| has_event(items, id))
175217 })
176218 .await;
177- let Event::TimelineReset { items, .. } = reset else {
178- unreachable!();
179- };
180- assert!(gap_in(&items).is_some());
181219 assert_eq!(
182220 wait_for_recent(&mut room, room_id).await,
183221 RecentHistoryState::Loaded
184222 );
185- let report = wait_for_report(&mut room, room_id).await;
186- assert_eq!(report.requested_policy, HistoryRepairPolicy::StartRecent);
187- assert_eq!(report.phase, HistoryRepairPhase::Ready);
188- assert_eq!(report.pages, 4);
189- assert_eq!(report.events, 4);
190- assert!(report.recent_available);
191223 let requests = room.server.received_requests().await.unwrap();
192224 let messages = requests
193225 .iter()
@@ -230,13 +262,10 @@ async fn explicit_recent_start_coalesces_with_the_automatic_load() {
230262 .await
231263 .expect("automatic recent request did not start");
232264
233- let report = room
234- .handle
235- .start_recent_history(room_id.to_owned())
265+ room.handle
266+ .retry_recent_history(room_id.to_owned())
236267 .await
237268 .unwrap();
238- assert_eq!(report.pages, 1);
239- assert_eq!(report.events, 1);
240269 assert_eq!(
241270 wait_for_recent(&mut room, room_id).await,
242271 RecentHistoryState::Loaded
@@ -358,8 +387,7 @@ async fn fill_gap_returns_after_the_mirror_contains_the_committed_rows() {
358387 loop {
359388 let snapshot = mirror_handle.snapshot();
360389 let active_gap = snapshot.repairs.get(room_id).is_some_and(|report| {
361- report.gap_id.as_ref() == Some(&target_gap_id)
362- && report.phase == HistoryRepairPhase::Bridging
390+ report.gap_id == target_gap_id && report.phase == HistoryRepairPhase::Bridging
363391 });
364392 if active_gap {
365393 break;
@@ -416,15 +444,8 @@ async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
416444 .await;
417445 cache_event_before_open(&room, room_id, old_id, "old cached").await;
418446 open_room(&mut room, room_id).await;
419- let reset = receive_until(&mut room.events, |event| {
420- matches!(event, Event::TimelineReset { timeline_id, items }
421- if timeline_id.room_id() == room_id && gap_in(items).is_some())
422- })
423- .await;
424- let gap = match reset {
425- Event::TimelineReset { items, .. } => gap_in(&items).unwrap(),
426- _ => unreachable!(),
427- };
447+ timeline_until(&mut room, |items| gap_in(items).is_some()).await;
448+ let gap = gap_in(&room.items).unwrap();
428449 assert_eq!(
429450 wait_for_recent(&mut room, room_id).await,
430451 RecentHistoryState::Loaded
@@ -487,6 +508,9 @@ async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
487508 .unwrap();
488509 let error = fill.await.unwrap().unwrap_err();
489510 assert_eq!(error.kind, macltok::ErrorKind::Interrupted);
511+ let report = wait_for_settled_fill(&mut room).await;
512+ assert_eq!(report.stop, Some(HistoryRepairStop::Cancelled));
513+ assert!(report.failure.is_none());
490514
491515 room.server
492516 .mock_room_messages()
@@ -505,22 +529,13 @@ async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
505529 .fill_history_gap(room_id.to_owned(), gap.id)
506530 .await
507531 .unwrap();
508- let final_reset = receive_until(&mut room.events, |event| {
509- matches!(event, Event::TimelineReset { timeline_id, items }
510- if timeline_id.room_id() == room_id
511- && items.iter().any(|item| matches!(item, TimelineItem::Event(event)
512- if event.event_id.as_deref() == Some(middle_id)))
513- && items.iter().any(|item| matches!(item, TimelineItem::Event(event)
514- if event.event_id.as_deref() == Some(old_id))))
532+ timeline_until(&mut room, |items| {
533+ [fresh_id, middle_id, old_id]
534+ .iter()
535+ .all(|id| has_event(items, id))
536+ && gap_in(items).is_none()
515537 })
516538 .await;
517- let ids = event_messages(&final_reset)
518- .iter()
519- .filter_map(|event| event.event_id.as_deref())
520- .collect::<Vec<_>>();
521- assert!(ids.contains(&fresh_id));
522- assert!(ids.contains(&middle_id));
523- assert!(ids.contains(&old_id));
524539 shut_down(&room.handle, &mut room.events, room.running).await;
525540 }
526541
@@ -542,8 +557,38 @@ async fn failed_automatic_recent_load_is_public_and_keeps_the_cached_timeline()
542557 open_room(&mut room, room_id).await;
543558 let state = wait_for_recent(&mut room, room_id).await;
544559 assert!(matches!(state, RecentHistoryState::Failed(_)));
545- let report = wait_for_report(&mut room, room_id).await;
546- assert_eq!(report.phase, HistoryRepairPhase::Paused);
547- assert!(report.failure.is_some());
560+ shut_down(&room.handle, &mut room.events, room.running).await;
561+}
562+
563+#[tokio::test]
564+async fn closing_during_a_recent_load_settles_idle_not_failed() {
565+ let room_id = room_id!("!close-recent:localhost");
566+ let mut room = start_room(room_id).await;
567+ room.server
568+ .mock_room_messages()
569+ .ok(RoomMessagesResponseTemplate::default()
570+ .with_delay(Duration::from_secs(5))
571+ .events(vec![history_event(
572+ room_id,
573+ event_id!("$never:localhost"),
574+ "too late",
575+ )]))
576+ .mock_once()
577+ .mount()
578+ .await;
579+ open_room(&mut room, room_id).await;
580+ room.handle.close_room(room_id.to_owned()).await.unwrap();
581+ let mut states = Vec::new();
582+ next_until(&mut room, |event| {
583+ if let Event::RecentHistoryChanged { state, .. } = event {
584+ states.push(state.clone());
585+ }
586+ matches!(event, Event::TimelineClosed { timeline_id } if timeline_id.room_id() == room_id)
587+ })
588+ .await;
589+ assert_eq!(
590+ states,
591+ [RecentHistoryState::Loading, RecentHistoryState::Idle]
592+ );
548593 shut_down(&room.handle, &mut room.events, room.running).await;
549594 }