atagen

macltok

public
Star0
Watch0

Commit c9f0fd0ee1cc

tarball

atagen <boss@atagen.co>2026-10-25 07:08 UTC

16 files changed+1096-1069
timeline: retain segmented room history
Modified file: Cargo.lock+10 additions−10 deletions
@@ -2002,7 +2002,7 @@ dependencies = [
20022002 [[package]]
20032003 name = "matrix-sdk"
20042004 version = "0.18.0"
2005-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2005+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20062006 dependencies = [
20072007 "anymap2",
20082008 "aquamarine",
@@ -2065,7 +2065,7 @@ dependencies = [
20652065 [[package]]
20662066 name = "matrix-sdk-base"
20672067 version = "0.18.0"
2068-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2068+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20692069 dependencies = [
20702070 "as_variant",
20712071 "assert_matches",
@@ -2095,7 +2095,7 @@ dependencies = [
20952095 [[package]]
20962096 name = "matrix-sdk-common"
20972097 version = "0.18.0"
2098-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2098+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20992099 dependencies = [
21002100 "eyeball-im",
21012101 "futures-core",
@@ -2118,7 +2118,7 @@ dependencies = [
21182118 [[package]]
21192119 name = "matrix-sdk-crypto"
21202120 version = "0.18.0"
2121-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2121+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
21222122 dependencies = [
21232123 "aes",
21242124 "aquamarine",
@@ -2159,7 +2159,7 @@ dependencies = [
21592159 [[package]]
21602160 name = "matrix-sdk-indexeddb"
21612161 version = "0.18.0"
2162-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2162+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
21632163 dependencies = [
21642164 "async-trait",
21652165 "base64",
@@ -2191,7 +2191,7 @@ dependencies = [
21912191 [[package]]
21922192 name = "matrix-sdk-sqlite"
21932193 version = "0.18.0"
2194-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2194+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
21952195 dependencies = [
21962196 "as_variant",
21972197 "async-trait",
@@ -2218,7 +2218,7 @@ dependencies = [
22182218 [[package]]
22192219 name = "matrix-sdk-store-encryption"
22202220 version = "0.18.0"
2221-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2221+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
22222222 dependencies = [
22232223 "base64",
22242224 "blake3",
@@ -2239,7 +2239,7 @@ dependencies = [
22392239 [[package]]
22402240 name = "matrix-sdk-test"
22412241 version = "0.18.0"
2242-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2242+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
22432243 dependencies = [
22442244 "as_variant",
22452245 "ctor",
@@ -2262,7 +2262,7 @@ dependencies = [
22622262 [[package]]
22632263 name = "matrix-sdk-test-macros"
22642264 version = "0.18.0"
2265-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2265+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
22662266 dependencies = [
22672267 "quote",
22682268 "syn 2.0.119",
@@ -2271,7 +2271,7 @@ dependencies = [
22712271 [[package]]
22722272 name = "matrix-sdk-ui"
22732273 version = "0.18.0"
2274-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=9b9b16982dcab5dd499547bb87e121b9d0cba6b5#9b9b16982dcab5dd499547bb87e121b9d0cba6b5"
2274+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
22752275 dependencies = [
22762276 "as_variant",
22772277 "async-rx",
Modified file: Cargo.toml+9 additions−9 deletions
@@ -26,11 +26,11 @@ indexeddb = [
2626 futures-util = { version = "0.3.31", default-features = false, features = ["std"] }
2727 http = "1"
2828 imbl = { version = "6.1.0", default-features = false }
29-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["e2e-encryption"] }
30-matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
31-matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
32-matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", optional = true }
33-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false }
29+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false, features = ["e2e-encryption"] }
30+matrix-sdk-base = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false }
31+matrix-sdk-crypto = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false }
32+matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", optional = true }
33+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false }
3434 ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }
3535 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
3636 serde_json = "1.0.143"
@@ -48,8 +48,8 @@ getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }
4848 getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }
4949 indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }
5050 js-sys = { version = "0.3.105", optional = true }
51-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["js"] }
52-matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["js"] }
51+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false, features = ["js"] }
52+matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false, features = ["js"] }
5353 # the sdk's own wasm reqwest leaves out stream, which the bounded media download needs
5454 reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }
5555 uuid = { version = "1", features = ["js"] }
@@ -64,8 +64,8 @@ web-sys = { version = "0.3.105", optional = true, features = [
6464 ] }
6565
6666 [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
67-matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5", default-features = false, features = ["testing"] }
68-matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "9b9b16982dcab5dd499547bb87e121b9d0cba6b5" }
67+matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e", default-features = false, features = ["testing"] }
68+matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "49a9cd1aff19df1de7e46710cb6c984a1bddc30e" }
6969 tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
7070 wiremock = "0.6.5"
7171
Added file: docs/segmented-history.md+164 additions
@@ -0,0 +1,164 @@
1+# Segmented history implementation
2+
3+The durable implementation is in the macltok `segmented-history` feature
4+worktree. Its SDK dependency has been published at
5+`49a9cd1aff19df1de7e46710cb6c984a1bddc30e` on the SDK `segmented-history`
6+bookmark. The macltok manifest and lockfile are being updated to that revision.
7+The macltok feature bookmark will also be named `segmented-history`. Malice has
8+not yet been pinned to the macltok revision.
9+
10+## Current behavior
11+
12+Opening a room loads retained history and its gap rows, then fetches a bounded
13+recent window from a fresh server position. The recent fetch is capped at four
14+pages of 50 raw events. It preserves retained sections and their older cursors.
15+If it cannot establish continuity, it installs a durable gap between the
16+sections. It stops when continuity is established or accessible history ends.
17+
18+`HistoryRepairCommand::FillGap` takes a room ID and stable gap transaction ID.
19+It fetches up to four backward pages of 50 events and advances only that gap's
20+saved cursor. Fill progress and events commit atomically. Concurrent history
21+operations for the room return `RoomBusy`. An empty page with an advancing
22+cursor keeps the gap. A repeated cursor pauses it. Accessible history ending
23+before the retained boundary marks the interval unavailable. Only reaching the
24+retained boundary joins sections. Already joined gaps are harmless to stale
25+callers.
26+
27+The cache stores gap identity, boundary evidence, cursor, and status alongside
28+the encrypted linked event chunks. SQLite supports legacy token data and the
29+new metadata. IndexedDB stores the optional encrypted metadata field. Room
30+close and restart retain sections and fill cursors. Timeline snapshots and
31+incremental updates project gaps as `TimelineItem::HistoryGap`. Gap rows retain
32+their identity while status changes. Joined markers disappear and unavailable
33+intervals remain visible.
34+
35+The recent fetch can complete while live sync continues. Cache installation
36+uses a candidate state and commits events, gap metadata, and cursor together.
37+Sync publication is serialized with gap installation. Existing loaded timelines
38+receive incremental insertions that preserve retained item identities and
39+pending sends. Loading history outside the current timeline window still needs
40+a reset.
41+
42+Each SDK room cache owns a coordinator task. Recent callers share an in-flight
43+request. Cancelling one caller preserves the operation for other subscribers,
44+and cancelling all callers stops fetching before installation. Started commits
45+finish before the coordinator accepts conflicting work. No mutex guard crosses
46+the network request. The idle task holds only a weak cache reference, so it does
47+not keep a room cache alive. The SDK UI calls `wait_for_history` as a fence,
48+then takes an atomic event and gap snapshot while replacing its cache
49+subscription. The macltok watcher is stopped before its timeline reset is sent,
50+so old diffs cannot arrive after the reset. A mirror publication acknowledgement
51+completes before the command response is released. The room actor starts recent
52+loading in the background when the room opens, so opening does not wait for the
53+network request.
54+
55+Limited sync results persist explicit gap markers in the event cache. Later sync
56+updates preserve those markers until the pagination chain reaches the retained
57+boundary. The integration uses the room's live timeline flush and watcher reset
58+path. It does not reseed the cache. Thread and pinned timelines remain open
59+through this update path.
60+
61+## Public and wire shapes
62+
63+`HistoryRepairCommand::FillGap { room_id, gap_id }` is the supported targeted
64+fill operation, sent through `ClientHandle::execute` as
65+`Command::HistoryRepair`. It returns `CommandOutput::Done` on success. The older
66+policy and staged repair command variants remain available for serde
67+compatibility, but room opening no longer consults the saved policy. `Continue`
68+is invalid because continuation must identify its gap. `GetPolicy` and
69+`SetPolicy` remain compatibility operations and do not control opening.
70+
71+Targeted fill progress is published as a `HistoryRepairReport` with `gap_id`
72+set and `phase` set to `Bridging`. This report supplies the loading state while
73+the fill runs. Recent loading has its own room keyed `RecentHistoryState` with
74+`Idle`, `Loading`, `Loaded`, or `Failed` values. The mirror exposes that state in
75+`recent_history`, separately from gap continuity and repair progress.
76+
77+`TimelineItem::HistoryGap` carries a `HistoryGap` with `id`,
78+`older_boundary`, `newer_boundary`, `lifecycle`, `continuity`, and `failure`.
79+Lifecycle values are `Idle`, `Fetching`, `Paused`, `Failed`, and `Unavailable`.
80+Continuity values are `Unresolved`, `Verified`, and `AccessibleStart`. The gap
81+ID is an owned transaction ID. Boundary values are optional owned event IDs.
82+Failure is an optional displayable string. `TimelineState.items` contains these
83+items in order with event rows. Mirror snapshots serialize the same item shape,
84+and incremental timeline changes carry it through the normal reset or changed
85+diff.
86+
87+Serde enums use externally tagged variants. A command payload is shaped as
88+follows, with the outer `Command` adding its `HistoryRepair` tag.
89+
90+```json
91+{
92+ "FillGap": {
93+ "room_id": "!room:example.org",
94+ "gap_id": "opaque-gap-transaction-id"
95+ }
96+}
97+```
98+
99+The final history repair publication acknowledgement is internal, skipped by
100+serde, and not part of application wire data. The mirror applies the report
101+before acknowledging it, so command completion follows publication.
102+
103+## Remaining publication and Malice work
104+
105+Publish the SDK revision on the user's fork, then publish the macltok revision
106+that pins it. Update the Malice macltok dependency in both native and worker
107+manifests and regenerate the applicable lockfiles against that same revision.
108+The current worktree has not recorded a final publishable commit or revision
109+for these changes.
110+
111+Malice should remove the saved policy and threshold controls from room opening.
112+Render each inline gap between its neighboring event sections. A fill should
113+preserve the visible event anchor, composer draft, reply or edit state, pending
114+sends, and encryption identity. Keep event-context windows independent from
115+live room history.
116+
117+## Lost interval investigation
118+
119+The reported failure showed retained events followed by newly sent messages,
120+with an omitted interval of roughly eight to ten hours presented as continuous.
121+Older pagination continued before the retained buffer, and a page refresh did
122+not recover the missing range. A refresh can reconnect to the existing
123+SharedWorker, so it does not prove that the engine restarted.
124+
125+The earlier SDK cache path could discard a limited-sync boundary token when a
126+timeline was reported unlimited despite cached events. It could also discard a
127+token when all incoming events were duplicates. Both cases can hide a real
128+discontinuity from older pagination. Initial pagination could also finish
129+against retained history before fresh sync arrived. These are concrete failure
130+paths, but none has been confirmed as the production cause. The durable gaps
131+preserve discontinuities when observed. Startup, subscription, and sync
132+diagnostics are still needed to identify where this particular interval was
133+lost.
134+
135+## Validation commands
136+
137+Run native checks from `../macltok-history-repair` with the repository's Nix
138+toolchain.
139+
140+```sh
141+nix develop --command cargo test --all-targets
142+nix develop --command cargo test --all-targets --features sqlite
143+nix develop --command cargo clippy --all-targets -- -D warnings
144+nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings
145+```
146+
147+For the browser worker runtime, use the web devshell. The harness starts a local
148+homeserver and runs both worker targets in Firefox.
149+
150+```sh
151+nix develop .#web -c nu harness/web/run.nu
152+```
153+
154+The flake checks include native and WebAssembly compilation with IndexedDB and
155+the web worker runtime. `nix flake check` is the aggregate gate and remains
156+pending.
157+
158+Seven focused SDK recent-history tests and seven segmented-history tests pass.
159+The SDK WebAssembly compile passes with IndexedDB enabled. The SDK UI timeline
160+checks pass with 123 unit tests, seven room unit tests, and 14 timed integration
161+tests. Two headless Firefox tests pass for the IndexedDB shared gap storage
162+contract with plaintext and encrypted stores. The full flake gate remains
163+pending. Complete worker restart and encrypted room recovery have not been
164+tested.
Modified file: harness/web/Cargo.lock+8 additions−8 deletions
@@ -1907,7 +1907,7 @@ dependencies = [
19071907 [[package]]
19081908 name = "matrix-sdk"
19091909 version = "0.18.0"
1910-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
1910+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
19111911 dependencies = [
19121912 "anymap2",
19131913 "aquamarine",
@@ -1967,7 +1967,7 @@ dependencies = [
19671967 [[package]]
19681968 name = "matrix-sdk-base"
19691969 version = "0.18.0"
1970-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
1970+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
19711971 dependencies = [
19721972 "as_variant",
19731973 "async-trait",
@@ -1993,7 +1993,7 @@ dependencies = [
19931993 [[package]]
19941994 name = "matrix-sdk-common"
19951995 version = "0.18.0"
1996-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
1996+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
19971997 dependencies = [
19981998 "eyeball-im",
19991999 "futures-core",
@@ -2016,7 +2016,7 @@ dependencies = [
20162016 [[package]]
20172017 name = "matrix-sdk-crypto"
20182018 version = "0.18.0"
2019-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2019+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20202020 dependencies = [
20212021 "aes",
20222022 "aquamarine",
@@ -2056,7 +2056,7 @@ dependencies = [
20562056 [[package]]
20572057 name = "matrix-sdk-indexeddb"
20582058 version = "0.18.0"
2059-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2059+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20602060 dependencies = [
20612061 "async-trait",
20622062 "base64",
@@ -2088,7 +2088,7 @@ dependencies = [
20882088 [[package]]
20892089 name = "matrix-sdk-sqlite"
20902090 version = "0.18.0"
2091-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2091+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
20922092 dependencies = [
20932093 "as_variant",
20942094 "async-trait",
@@ -2115,7 +2115,7 @@ dependencies = [
21152115 [[package]]
21162116 name = "matrix-sdk-store-encryption"
21172117 version = "0.18.0"
2118-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2118+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
21192119 dependencies = [
21202120 "base64",
21212121 "blake3",
@@ -2136,7 +2136,7 @@ dependencies = [
21362136 [[package]]
21372137 name = "matrix-sdk-ui"
21382138 version = "0.18.0"
2139-source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=7aace883fbe03d4c3c118b55188fe9519ba8ce48#7aace883fbe03d4c3c118b55188fe9519ba8ce48"
2139+source = "git+ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk?rev=49a9cd1aff19df1de7e46710cb6c984a1bddc30e#49a9cd1aff19df1de7e46710cb6c984a1bddc30e"
21402140 dependencies = [
21412141 "as_variant",
21422142 "async-rx",
Modified file: src/engine/mod.rs+2 additions−12 deletions
@@ -326,8 +326,6 @@ pub struct Engine {
326326 mentions: crate::mentions::service::Handle,
327327 mentions_service: Option<crate::mentions::service::Service>,
328328 mentions_updates: Option<watch::Receiver<crate::MentionsPage>>,
329- repair_gate: Arc<tokio::sync::Mutex<()>>,
330- repair_shutdown: Arc<std::sync::atomic::AtomicBool>,
331329 }
332330
333331 impl Engine {
@@ -437,8 +435,6 @@ impl Engine {
437435 mentions,
438436 mentions_service: Some(mentions_service),
439437 mentions_updates: Some(mentions_updates),
440- repair_gate: Arc::default(),
441- repair_shutdown: Arc::default(),
442438 };
443439 Ok((engine, handle, events))
444440 }
@@ -988,7 +984,8 @@ impl Engine {
988984 HistoryRepairCommand::Start { room_id, .. }
989985 | HistoryRepairCommand::Continue { room_id, .. }
990986 | HistoryRepairCommand::Cancel { room_id }
991- | HistoryRepairCommand::StartRecent { room_id } => room_id,
987+ | HistoryRepairCommand::StartRecent { room_id }
988+ | HistoryRepairCommand::FillGap { room_id, .. } => room_id,
992989 HistoryRepairCommand::GetPolicy | HistoryRepairCommand::SetPolicy(_) => {
993990 let mentions = self.mentions.clone();
994991 self.tasks
@@ -1084,9 +1081,6 @@ impl Engine {
10841081 initial_history_limit: self.config.initial_history_limit,
10851082 supervisor,
10861083 mentions: self.mentions.clone(),
1087- sync: self.sync.clone(),
1088- repair_gate: self.repair_gate.clone(),
1089- repair_shutdown: self.repair_shutdown.clone(),
10901084 },
10911085 panics,
10921086 opening: vec![reply],
@@ -1165,15 +1159,11 @@ impl Engine {
11651159 }
11661160
11671161 async fn shutdown(&mut self) {
1168- self.repair_shutdown
1169- .store(true, std::sync::atomic::Ordering::Release);
11701162 if let Some(handler) = self.verification_handler.take() {
11711163 self.client.remove_event_handler(handler);
11721164 }
11731165 self.port.close();
1174- let repair_gate = self.repair_gate.lock().await;
11751166 self.sync.stop().await;
1176- drop(repair_gate);
11771167 if self.mentions_service.is_none() {
11781168 self.mentions.stop().await;
11791169 }
Modified file: src/engine/repair.rs+337 additions−505 deletions
@@ -1,68 +1,67 @@
1-use std::{
2- collections::HashSet,
3- sync::{
4- Arc,
5- atomic::{AtomicBool, Ordering},
6- },
7- time::Duration,
8-};
9-
10-use matrix_sdk::{Room, deserialized_responses::TimelineEvent, room::MessagesOptions};
11-use matrix_sdk_ui::sync_service::SyncService;
12-use ruma::{OwnedEventId, time::Instant};
13-use tokio::sync::{OwnedMutexGuard, mpsc, oneshot, watch};
1+use matrix_sdk::{Room, event_cache::HistoryGapFillOutcome};
2+use tokio::sync::watch;
143
154 use super::{
16- executor::{JoinHandle, spawn},
5+ executor::{JoinError, JoinHandle, spawn},
176 timeline::SessionContext,
187 };
198 use crate::{
20- CommandOutput, Error, ErrorKind, Event, HistoryContinuity, HistoryRepairBudget,
21- HistoryRepairCommand, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport,
22- HistoryRepairStop, HistoryRepairStrategy, transport::Responder,
9+ CommandOutput, Error, ErrorKind, HistoryContinuity, HistoryRepairBudget, HistoryRepairCommand,
10+ HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop,
11+ HistoryRepairStrategy, RecentHistoryState, transport::Responder,
2312 };
2413
25-const PAGE_SIZE: u16 = 50;
26-const STAGING_LIMIT: usize = 10_000;
14+const MAX_GAP_PAGES: u32 = 4;
2715
28-pub(super) struct InstallRequest {
29- pub(super) events: Vec<TimelineEvent>,
30- pub(super) prev_batch: Option<String>,
31- pub(super) reply: oneshot::Sender<Result<(), Error>>,
16+struct Running {
17+ task: JoinHandle<CompletionResult>,
18+ cancel: watch::Sender<bool>,
19+ responders: Vec<PendingResponder>,
20+ recent: bool,
3221 }
3322
34-struct Stage {
35- events: Vec<TimelineEvent>,
36- cursor: Option<String>,
37- anchor: Option<OwnedEventId>,
38- report: HistoryRepairReport,
23+struct PendingResponder {
24+ responder: Responder,
25+ cancel_ack: bool,
3926 }
4027
41-struct Running {
42- task: JoinHandle<Stage>,
43- cancel: watch::Sender<bool>,
44- reply: Responder,
28+pub(super) struct Completion {
29+ pub(super) report: HistoryRepairReport,
30+ pub(super) recent_state: Option<RecentHistoryState>,
31+ pub(super) result: Result<CommandOutput, Error>,
32+ responders: Vec<PendingResponder>,
33+}
34+
35+impl Completion {
36+ pub(super) fn resolve(self, result: Result<CommandOutput, Error>, cancellation: Result<(), Error>) {
37+ for responder in self.responders {
38+ if responder.cancel_ack {
39+ responder.responder.done(cancellation.clone());
40+ } else {
41+ responder.responder.send(result.clone());
42+ }
43+ }
44+ }
45+}
46+
47+struct CompletionResult {
48+ report: HistoryRepairReport,
49+ recent_state: Option<RecentHistoryState>,
50+ result: Result<CommandOutput, Error>,
4551 }
4652
4753 pub(super) struct RepairController {
4854 room: Room,
4955 context: SessionContext,
50- installs: mpsc::Sender<InstallRequest>,
51- requests: mpsc::Receiver<InstallRequest>,
5256 running: Option<Running>,
53- stage: Option<Stage>,
5457 }
5558
5659 impl RepairController {
5760 pub(super) fn new(room: Room, context: SessionContext) -> Self {
58- let (installs, requests) = mpsc::channel(1);
5961 Self {
6062 room,
6163 context,
62- installs,
63- requests,
6464 running: None,
65- stage: None,
6665 }
6766 }
6867
@@ -70,202 +69,179 @@ impl RepairController {
7069 self.running.is_some()
7170 }
7271
72+ pub(super) async fn start_recent(&mut self) {
73+ self.start_recent_with(HistoryRepairPolicy::default(), None, true, Vec::new())
74+ .await;
75+ }
76+
7377 pub(super) async fn execute(&mut self, command: HistoryRepairCommand, reply: Responder) {
74- if matches!(command, HistoryRepairCommand::Cancel { .. }) {
75- if let Some(running) = &self.running {
76- running.cancel.send_replace(true);
78+ match command {
79+ HistoryRepairCommand::Cancel { .. } => {
80+ if let Some(running) = self.running.as_mut() {
81+ running.responders.push(PendingResponder {
82+ responder: reply,
83+ cancel_ack: true,
84+ });
85+ running.cancel.send_replace(true);
86+ } else {
87+ reply.done(Ok(()));
88+ }
7789 }
78- reply.done(Ok(()));
79- return;
80- }
81- if self.is_running() {
82- reply.done(Err(Error::new(
83- ErrorKind::RoomBusy,
84- "room history repair is running",
85- )));
86- return;
87- }
88- let (stage, budget) = match command {
8990 HistoryRepairCommand::Start { policy, budget, .. } => {
9091 if let Err(error) = validate_budget(&budget) {
9192 reply.done(Err(error));
9293 return;
9394 }
94- let policy = match policy {
95- Some(policy) => policy,
96- None => match self.context.mentions.repair_policy().await {
97- Ok(policy) => policy,
98- Err(error) => {
99- reply.done(Err(error));
100- return;
101- }
102- },
103- };
104- let coverage = match self
105- .context
106- .mentions
107- .repair_coverage(self.room.room_id().to_owned())
108- .await
109- {
110- Ok(coverage) => coverage,
111- Err(error) => {
112- reply.done(Err(error));
113- return;
114- }
115- };
116- let duration = coverage.duration_ms();
117- let report = HistoryRepairReport {
118- room_id: self.room.room_id().to_owned(),
119- requested_policy: policy.clone(),
120- strategy: HistoryRepairStrategy::Recent,
121- coverage_duration_ms: duration,
122- threshold_evaluated: duration.is_some()
123- && coverage.unresolved
124- && matches!(policy, HistoryRepairPolicy::BridgeShortGaps { .. }),
125- pages: 0,
126- events: 0,
127- phase: HistoryRepairPhase::FetchingRecent,
128- continuity: HistoryContinuity::Unresolved,
129- recent_available: false,
130- stop: None,
131- failure: None,
132- };
133- (
134- Stage {
135- events: Vec::new(),
136- cursor: None,
137- anchor: None,
138- report,
139- },
140- budget,
95+ let policy = policy.unwrap_or_default();
96+ self.start_recent_with(policy, Some(reply), true, Vec::new())
97+ .await;
98+ }
99+ HistoryRepairCommand::StartRecent { .. } => {
100+ self.start_recent_with(
101+ HistoryRepairPolicy::default(),
102+ Some(reply),
103+ true,
104+ Vec::new(),
141105 )
106+ .await;
142107 }
143- HistoryRepairCommand::Continue { budget, .. } => {
144- if let Err(error) = validate_budget(&budget) {
145- reply.done(Err(error));
146- return;
147- }
148- let Some(mut stage) = self.stage.take() else {
149- reply.done(Err(Error::new(
150- ErrorKind::Invalid,
151- "no history bridge to continue",
152- )));
153- return;
154- };
155- if !stage.report.recent_available
156- || stage.report.continuity != HistoryContinuity::Unresolved
157- {
158- reply.done(Err(Error::new(
159- ErrorKind::Invalid,
160- "history bridge is not resumable",
161- )));
162- self.stage = Some(stage);
163- return;
164- }
165- stage.report.strategy = HistoryRepairStrategy::Bridge;
166- stage.report.phase = HistoryRepairPhase::Bridging;
167- stage.report.stop = None;
168- stage.report.failure = None;
169- (stage, budget)
108+ HistoryRepairCommand::FillGap { gap_id, .. } => {
109+ self.start_gap_fill(gap_id, reply).await;
170110 }
171- HistoryRepairCommand::StartRecent { .. } => {
172- let Some(mut stage) = self.stage.take() else {
173- reply.done(Err(Error::new(
174- ErrorKind::Invalid,
175- "no recent history repair",
176- )));
177- return;
178- };
179- if !stage.report.recent_available {
180- self.stage = Some(stage);
181- reply.done(Err(Error::new(
182- ErrorKind::Invalid,
183- "recent history is not available, retry the repair",
184- )));
185- return;
111+ HistoryRepairCommand::Continue { .. } => reply.done(Err(Error::new(
112+ ErrorKind::Invalid,
113+ "continue requires a history gap ID, use FillGap",
114+ ))),
115+ HistoryRepairCommand::GetPolicy => {
116+ reply.send(
117+ self.context
118+ .mentions
119+ .repair_policy()
120+ .await
121+ .map(CommandOutput::HistoryRepairPolicy),
122+ );
123+ }
124+ HistoryRepairCommand::SetPolicy(policy) => {
125+ reply.done(self.context.mentions.set_repair_policy(policy).await);
126+ }
127+ }
128+ }
129+
130+ async fn start_recent_with(
131+ &mut self,
132+ policy: HistoryRepairPolicy,
133+ responder: Option<Responder>,
134+ publish_recent: bool,
135+ mut responders: Vec<PendingResponder>,
136+ ) {
137+ if let Some(running) = &mut self.running {
138+ if let Some(responder) = responder {
139+ if running.recent {
140+ running.responders.push(PendingResponder { responder, cancel_ack: false });
141+ } else {
142+ responder.done(Err(room_busy()));
186143 }
187- stage.report.strategy = HistoryRepairStrategy::Recent;
188- stage.report.phase = HistoryRepairPhase::Ready;
189- stage.report.stop = None;
190- stage.report.failure = None;
191- let report = stage.report.clone();
192- let published = self
193- .context
194- .events
195- .publish_repair(report.clone())
196- .await
197- .map_err(|_| Error::interrupted());
198- reply.send(published.map(|_| CommandOutput::HistoryRepair(report)));
199- return;
200144 }
201- _ => {
202- reply.done(Err(Error::new(
203- ErrorKind::Invalid,
204- "repair command was misrouted",
205- )));
206- return;
145+ return;
146+ }
147+ if let Some(responder) = responder {
148+ responders.push(PendingResponder {
149+ responder,
150+ cancel_ack: false,
151+ });
152+ }
153+ if publish_recent
154+ && self
155+ .context
156+ .events
157+ .send(crate::Event::RecentHistoryChanged {
158+ room_id: self.room.room_id().to_owned(),
159+ state: RecentHistoryState::Loading,
160+ })
161+ .await
162+ .is_err()
163+ {
164+ for responder in responders {
165+ responder.responder.done(Err(Error::interrupted()));
207166 }
208- };
167+ return;
168+ }
209169 let (cancel, receiver) = watch::channel(false);
210170 let room = self.room.clone();
211- let context = self.context.clone();
212- let installs = self.installs.clone();
213- let task =
214- spawn(async move { run(room, context, installs, receiver, stage, budget).await });
171+ let room_id = self.room.room_id().to_owned();
172+ let task = spawn(async move { load_recent(room, room_id, policy, receiver).await });
215173 self.running = Some(Running {
216174 task,
217175 cancel,
218- reply,
176+ responders,
177+ recent: true,
219178 });
220179 }
221180
222- pub(super) async fn next_install(&mut self) -> Option<InstallRequest> {
223- let running = self.running.as_mut()?;
224- tokio::select! {
225- request = self.requests.recv() => request,
226- result = &mut running.task => {
227- let running = self.running.take().expect("repair task exists");
228- match result {
229- Ok(stage) => {
230- let report = stage.report.clone();
231- let published = self.context.events.publish_repair(report.clone()).await
232- .map_err(|_| Error::interrupted());
233- running.reply.send(published.map(|_| CommandOutput::HistoryRepair(report)));
234- self.stage = Some(stage);
235- }
236- Err(_) => running.reply.done(Err(Error::interrupted())),
237- }
238- None
239- }
181+ async fn start_gap_fill(&mut self, gap_id: ruma::OwnedTransactionId, responder: Responder) {
182+ if self.is_running() {
183+ responder.done(Err(room_busy()));
184+ return;
185+ }
186+ let room = self.room.clone();
187+ let room_id = self.room.room_id().to_owned();
188+ let mut progress = report(room_id.clone(), HistoryRepairPolicy::default());
189+ progress.gap_id = Some(gap_id.clone());
190+ progress.strategy = HistoryRepairStrategy::Bridge;
191+ progress.phase = HistoryRepairPhase::Bridging;
192+ if self.context.events.send(crate::Event::HistoryRepairChanged(progress)).await.is_err() {
193+ responder.done(Err(Error::interrupted()));
194+ return;
240195 }
196+ let (cancel, receiver) = watch::channel(false);
197+ let task = spawn(async move { fill_gap(room, room_id, gap_id, receiver).await });
198+ self.running = Some(Running {
199+ task,
200+ cancel,
201+ responders: vec![PendingResponder {
202+ responder,
203+ cancel_ack: false,
204+ }],
205+ recent: false,
206+ });
241207 }
242208
243- pub(super) async fn shutdown(&mut self) {
244- if let Some(mut running) = self.running.take() {
245- running.task.abort();
246- match (&mut running.task).await {
247- Ok(stage) => {
248- let report = stage.report;
249- let published = self
250- .context
251- .events
252- .publish_repair(report.clone())
253- .await
254- .map_err(|_| Error::interrupted());
255- running
256- .reply
257- .send(published.map(|_| CommandOutput::HistoryRepair(report)));
258- }
259- Err(_) => running.reply.done(Err(Error::interrupted())),
260- }
261- }
209+ pub(super) async fn next_completion(&mut self) -> Option<Completion> {
210+ let running = self.running.as_mut()?;
211+ let result = (&mut running.task).await;
212+ Some(self.finish_task(result))
262213 }
263-}
264214
265-impl Drop for RepairController {
266- fn drop(&mut self) {
267- if let Some(running) = &self.running {
268- running.task.abort();
215+ pub(super) async fn shutdown(&mut self) -> Option<Completion> {
216+ let running = self.running.as_mut()?;
217+ running.cancel.send_replace(true);
218+ let result = (&mut running.task).await;
219+ Some(self.finish_task(result))
220+ }
221+
222+ fn finish_task(&mut self, result: Result<CompletionResult, JoinError>) -> Completion {
223+ let running = self.running.take().expect("repair task exists");
224+ let result = match result {
225+ Ok(result) => result,
226+ Err(_) => CompletionResult {
227+ report: failed_report(
228+ self.room.room_id().to_owned(),
229+ HistoryRepairPolicy::default(),
230+ Error::interrupted(),
231+ ),
232+ recent_state: running.recent.then(|| {
233+ RecentHistoryState::Failed(
234+ "recent history operation was interrupted".to_owned(),
235+ )
236+ }),
237+ result: Err(Error::interrupted()),
238+ },
239+ };
240+ Completion {
241+ report: result.report,
242+ recent_state: result.recent_state,
243+ result: result.result,
244+ responders: running.responders,
269245 }
270246 }
271247 }
@@ -283,326 +259,182 @@ fn validate_budget(budget: &HistoryRepairBudget) -> Result<(), Error> {
283259 Ok(())
284260 }
285261
286-fn should_bridge(policy: &HistoryRepairPolicy, duration: Option<u64>, boundary: bool) -> bool {
287- match policy {
288- HistoryRepairPolicy::Bridge => true,
289- HistoryRepairPolicy::StartRecent => false,
290- HistoryRepairPolicy::BridgeShortGaps { threshold_ms } => {
291- boundary
292- && *threshold_ms > 0
293- && duration.is_some_and(|duration| duration <= *threshold_ms)
294- }
295- }
296-}
297-
298-async fn run(
262+async fn load_recent(
299263 room: Room,
300- context: SessionContext,
301- installs: mpsc::Sender<InstallRequest>,
264+ room_id: ruma::OwnedRoomId,
265+ policy: HistoryRepairPolicy,
302266 mut cancel: watch::Receiver<bool>,
303- mut stage: Stage,
304- budget: HistoryRepairBudget,
305-) -> Stage {
306- let started = Instant::now();
307- let initial_pages = stage.report.pages;
308- let initial_events = stage.report.events;
309- let result = async {
310- if !stage.report.recent_available {
267+) -> CompletionResult {
268+ let base = report(room_id.clone(), policy);
269+ let outcome = tokio::select! {
270+ biased;
271+ _ = cancel.changed() => Err(Error::interrupted()),
272+ result = async {
311273 let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
312- stage.anchor = cache
313- .events()
314- .await
315- .map_err(cache_error)?
316- .iter()
317- .find_map(TimelineEvent::event_id);
318- let _ = context
319- .events
320- .send(Event::HistoryRepairChanged(stage.report.clone()))
321- .await;
322- let guard = pause_sync(&context, &mut cancel, &started, &budget).await?;
323- let page = fetch(&room, None, &mut cancel, &started, &budget).await?;
324- stage.report.pages += 1;
325- stage.report.events += page.chunk.len() as u32;
326- stage.cursor = page.end;
327- stage.events = page.chunk.into_iter().rev().collect();
328- deduplicate(&mut stage.events);
329- install(&installs, &stage).await?;
330- stage.report.recent_available = true;
331- if contains_anchor(&stage) {
332- stage.report.strategy = HistoryRepairStrategy::Overlap;
333- stage.report.continuity = HistoryContinuity::Verified;
334- } else if stage.cursor.is_none() {
335- stage.report.continuity = HistoryContinuity::AccessibleStart;
336- }
337- if stage.report.continuity != HistoryContinuity::Unresolved {
338- context
339- .mentions
340- .repair_resolved(room.room_id().to_owned())
341- .await?;
342- }
343- guard.finish().await;
344- let _ = context
345- .events
346- .send(Event::HistoryRepairChanged(stage.report.clone()))
347- .await;
348- if stage.report.continuity != HistoryContinuity::Unresolved {
349- return Ok(());
274+ cache.load_recent_history().await.map_err(cache_error)
275+ } => result,
276+ };
277+ match outcome {
278+ Ok(outcome) => {
279+ let mut report = base;
280+ report.pages = outcome.pages;
281+ report.events = outcome.events.min(u32::MAX as usize) as u32;
282+ report.recent_available = true;
283+ report.phase = HistoryRepairPhase::Ready;
284+ match outcome.gap.map(|gap| gap.status) {
285+ None => report.continuity = HistoryContinuity::AccessibleStart,
286+ Some(matrix_sdk::event_cache::HistoryGapStatus::Joined) => {
287+ report.continuity = HistoryContinuity::Verified
288+ }
289+ Some(matrix_sdk::event_cache::HistoryGapStatus::Unavailable) => {
290+ report.continuity = HistoryContinuity::AccessibleStart;
291+ report.stop = Some(HistoryRepairStop::HistoryUnavailable);
292+ report.phase = HistoryRepairPhase::Paused;
293+ }
294+ Some(matrix_sdk::event_cache::HistoryGapStatus::Paused) => {
295+ report.stop = Some(HistoryRepairStop::CursorStalled);
296+ report.phase = HistoryRepairPhase::Paused;
297+ }
298+ Some(matrix_sdk::event_cache::HistoryGapStatus::Failed { message }) => {
299+ let error = Error::new(ErrorKind::Network, message.clone());
300+ report.failure = Some(error.clone());
301+ report.phase = HistoryRepairPhase::Paused;
302+ }
303+ Some(matrix_sdk::event_cache::HistoryGapStatus::Idle) => {}
350304 }
351- if !should_bridge(
352- &stage.report.requested_policy,
353- stage.report.coverage_duration_ms,
354- stage.report.threshold_evaluated,
355- ) {
356- return Ok(());
305+ CompletionResult {
306+ report: report.clone(),
307+ recent_state: Some(RecentHistoryState::Loaded),
308+ result: Ok(CommandOutput::HistoryRepair(report)),
357309 }
358- stage.report.strategy = HistoryRepairStrategy::Bridge;
359- }
360- stage.report.phase = HistoryRepairPhase::Bridging;
361- let _ = context
362- .events
363- .send(Event::HistoryRepairChanged(stage.report.clone()))
364- .await;
365- if contains_anchor(&stage) {
366- stage.report.continuity = HistoryContinuity::Verified;
367- } else if stage.cursor.is_none() {
368- stage.report.continuity = HistoryContinuity::AccessibleStart;
369310 }
370- while stage.report.continuity == HistoryContinuity::Unresolved {
371- if *cancel.borrow() {
372- stage.report.stop = Some(HistoryRepairStop::Cancelled);
373- break;
374- }
375- if stage.report.pages - initial_pages + 1 >= u32::from(budget.max_pages)
376- || stage.report.events - initial_events + 2 * u32::from(PAGE_SIZE)
377- > budget.max_events
378- || stage.events.len() + 2 * usize::from(PAGE_SIZE) > STAGING_LIMIT
379- || started.elapsed() >= Duration::from_millis(budget.max_duration_ms)
380- {
381- stage.report.stop = Some(HistoryRepairStop::Budget);
382- break;
383- }
384- if stage.anchor.is_none() {
385- stage.report.stop = Some(HistoryRepairStop::NoAnchor);
386- break;
387- }
388- let Some(cursor) = stage.cursor.clone() else {
389- break;
311+ Err(error) => CompletionResult {
312+ report: failed_report(room_id, base.requested_policy, error.clone()),
313+ recent_state: Some(RecentHistoryState::Failed(error.message.clone())),
314+ result: Err(error),
315+ },
316+ }
317+}
318+
319+async fn fill_gap(
320+ room: Room,
321+ room_id: ruma::OwnedRoomId,
322+ gap_id: ruma::OwnedTransactionId,
323+ mut cancel: watch::Receiver<bool>,
324+) -> CompletionResult {
325+ let mut report = report(room_id, HistoryRepairPolicy::default());
326+ report.gap_id = Some(gap_id.clone());
327+ report.strategy = HistoryRepairStrategy::Bridge;
328+ let outcome = async {
329+ let (cache, _handles) = room.event_cache().await.map_err(cache_error)?;
330+ let mut last_gap = None;
331+ for _ in 0..MAX_GAP_PAGES {
332+ let next = tokio::select! {
333+ biased;
334+ _ = cancel.changed() => return Err(Error::interrupted()),
335+ result = cache.fill_history_gap_once(gap_id.clone()) => result.map_err(cache_error)?,
390336 };
391- let page = fetch(&room, Some(&cursor), &mut cancel, &started, &budget).await?;
392- stage.report.pages += 1;
393- stage.report.events += page.chunk.len() as u32;
394- if page.end.as_deref() == Some(&cursor) {
395- stage.report.stop = Some(HistoryRepairStop::CursorStalled);
396- break;
397- }
398- let mut older: Vec<_> = page.chunk.into_iter().rev().collect();
399- older.append(&mut stage.events);
400- deduplicate(&mut older);
401- stage.events = older;
402- stage.cursor = page.end;
403- if contains_anchor(&stage) {
404- stage.report.continuity = HistoryContinuity::Verified;
405- } else if stage.cursor.is_none() {
406- stage.report.continuity = HistoryContinuity::AccessibleStart;
407- }
408- if stage.report.continuity == HistoryContinuity::Unresolved {
409- let _ = context
410- .events
411- .send(Event::HistoryRepairChanged(stage.report.clone()))
412- .await;
337+ match next {
338+ HistoryGapFillOutcome::NotFound => {
339+ return Err(Error::new(ErrorKind::EventNotFound, "history gap was not found"));
340+ }
341+ HistoryGapFillOutcome::Busy => return Err(room_busy()),
342+ HistoryGapFillOutcome::Updated { gap, events } => {
343+ report.pages += 1;
344+ report.events = report.events.saturating_add(events.min(u32::MAX as usize) as u32);
345+ match gap.status.clone() {
346+ matrix_sdk::event_cache::HistoryGapStatus::Joined => {
347+ report.continuity = HistoryContinuity::Verified;
348+ last_gap = Some(gap);
349+ break;
350+ }
351+ matrix_sdk::event_cache::HistoryGapStatus::Unavailable => {
352+ report.continuity = HistoryContinuity::AccessibleStart;
353+ report.stop = Some(HistoryRepairStop::HistoryUnavailable);
354+ last_gap = Some(gap);
355+ break;
356+ }
357+ matrix_sdk::event_cache::HistoryGapStatus::Paused => {
358+ report.stop = Some(HistoryRepairStop::CursorStalled);
359+ last_gap = Some(gap);
360+ break;
361+ }
362+ matrix_sdk::event_cache::HistoryGapStatus::Failed { message } => {
363+ return Err(Error::new(ErrorKind::Network, message));
364+ }
365+ matrix_sdk::event_cache::HistoryGapStatus::Idle => last_gap = Some(gap),
366+ }
367+ }
413368 }
414369 }
415- if stage.report.continuity != HistoryContinuity::Unresolved
416- && stage.report.strategy == HistoryRepairStrategy::Bridge
370+ if report.pages == MAX_GAP_PAGES
371+ && report.continuity == HistoryContinuity::Unresolved
372+ && report.stop.is_none()
417373 {
418- let guard = pause_sync(&context, &mut cancel, &started, &budget).await?;
419- let head = fetch(&room, None, &mut cancel, &started, &budget).await?;
420- stage.report.pages += 1;
421- stage.report.events += head.chunk.len() as u32;
422- let head: Vec<_> = head.chunk.into_iter().rev().collect();
423- if !extend_head(&mut stage.events, head) {
424- stage.report.continuity = HistoryContinuity::Unresolved;
425- stage.report.stop = Some(HistoryRepairStop::HistoryUnavailable);
426- return Ok(());
427- }
428- install(&installs, &stage).await?;
429- context
430- .mentions
431- .repair_resolved(room.room_id().to_owned())
432- .await?;
433- guard.finish().await;
374+ report.stop = Some(HistoryRepairStop::Budget);
434375 }
435- Ok::<_, Error>(())
376+ Ok::<_, Error>(last_gap)
436377 }
437378 .await;
438- if let Err(error) = result {
439- if error.kind == ErrorKind::Interrupted {
440- stage.report.stop = Some(HistoryRepairStop::Cancelled);
441- } else if error.kind == ErrorKind::RoomBusy {
442- stage.report.stop = Some(HistoryRepairStop::Budget);
443- } else {
444- stage.report.failure = Some(error);
445- }
446- stage.report.continuity = HistoryContinuity::Unresolved;
447- }
448- stage.report.phase = if stage.report.stop.is_some() || stage.report.failure.is_some() {
449- HistoryRepairPhase::Paused
450- } else {
451- HistoryRepairPhase::Ready
452- };
453- stage
454-}
455-
456-fn contains_anchor(stage: &Stage) -> bool {
457- stage.anchor.as_ref().is_some_and(|anchor| {
458- stage
459- .events
460- .iter()
461- .any(|event| event.event_id().as_ref() == Some(anchor))
462- })
463-}
464-
465-fn deduplicate(events: &mut Vec<TimelineEvent>) {
466- let mut seen = HashSet::new();
467- events.retain(|event| event.event_id().is_some_and(|id| seen.insert(id)));
468-}
469-
470-fn extend_head(events: &mut Vec<TimelineEvent>, head: Vec<TimelineEvent>) -> bool {
471- let ids: HashSet<_> = events.iter().filter_map(TimelineEvent::event_id).collect();
472- let Some(overlap) = head
473- .iter()
474- .position(|event| event.event_id().is_some_and(|id| ids.contains(&id)))
475- else {
476- return events.is_empty() && head.is_empty();
477- };
478- events.extend(head.into_iter().skip(overlap + 1));
479- deduplicate(events);
480- true
481-}
482-
483-async fn install(installs: &mpsc::Sender<InstallRequest>, stage: &Stage) -> Result<(), Error> {
484- let (reply, response) = oneshot::channel();
485- installs
486- .send(InstallRequest {
487- events: stage.events.clone(),
488- prev_batch: stage.cursor.clone(),
489- reply,
490- })
491- .await
492- .map_err(|_| Error::interrupted())?;
493- response.await.map_err(|_| Error::interrupted())?
494-}
495-
496-async fn fetch(
497- room: &Room,
498- from: Option<&str>,
499- cancel: &mut watch::Receiver<bool>,
500- started: &Instant,
501- budget: &HistoryRepairBudget,
502-) -> Result<matrix_sdk::room::Messages, Error> {
503- let remaining = Duration::from_millis(budget.max_duration_ms).saturating_sub(started.elapsed());
504- if *cancel.borrow() {
505- return Err(Error::interrupted());
506- }
507- let mut options = MessagesOptions::backward().from(from);
508- options.limit = PAGE_SIZE.into();
509- tokio::select! {
510- _ = cancel.changed() => Err(Error::interrupted()),
511- result = matrix_sdk::timeout::timeout(room.messages(options), remaining) => {
512- let page = result.map_err(|_| Error::new(ErrorKind::RoomBusy, "repair work duration exhausted"))?
513- .map_err(|source| Error::from_sdk("fetching repair history failed", source))?;
514- if page.chunk.len() > usize::from(PAGE_SIZE) {
515- return Err(Error::new(ErrorKind::Invalid, "server exceeded the repair page limit"));
516- }
517- Ok(page)
379+ let command_result = match outcome {
380+ Ok(_) => {
381+ report.recent_available = true;
382+ report.phase = if report.stop.is_some() {
383+ HistoryRepairPhase::Paused
384+ } else {
385+ HistoryRepairPhase::Ready
386+ };
387+ Ok(CommandOutput::Done)
518388 }
519- }
520-}
521-
522-struct SyncPause {
523- sync: Arc<SyncService>,
524- shutdown: Arc<AtomicBool>,
525- gate: Option<OwnedMutexGuard<()>>,
526-}
527-
528-impl SyncPause {
529- async fn finish(mut self) {
530- if !self.shutdown.load(Ordering::Acquire) {
531- self.sync.start().await;
389+ Err(error) => {
390+ report.failure = Some(error.clone());
391+ report.phase = HistoryRepairPhase::Paused;
392+ Err(error)
532393 }
533- self.gate.take();
394+ };
395+ CompletionResult {
396+ report,
397+ recent_state: None,
398+ result: command_result,
534399 }
535400 }
536401
537-impl Drop for SyncPause {
538- fn drop(&mut self) {
539- if let Some(gate) = self.gate.take() {
540- let sync = self.sync.clone();
541- let shutdown = self.shutdown.clone();
542- spawn(async move {
543- if !shutdown.load(Ordering::Acquire) {
544- sync.start().await;
545- }
546- drop(gate);
547- });
548- }
402+fn report(room_id: ruma::OwnedRoomId, policy: HistoryRepairPolicy) -> HistoryRepairReport {
403+ HistoryRepairReport {
404+ room_id,
405+ gap_id: None,
406+ requested_policy: policy,
407+ strategy: HistoryRepairStrategy::Recent,
408+ coverage_duration_ms: None,
409+ threshold_evaluated: false,
410+ pages: 0,
411+ events: 0,
412+ phase: HistoryRepairPhase::FetchingRecent,
413+ continuity: HistoryContinuity::Unresolved,
414+ recent_available: false,
415+ stop: None,
416+ failure: None,
549417 }
550418 }
551419
552-async fn pause_sync(
553- context: &SessionContext,
554- cancel: &mut watch::Receiver<bool>,
555- started: &Instant,
556- budget: &HistoryRepairBudget,
557-) -> Result<SyncPause, Error> {
558- let remaining = Duration::from_millis(budget.max_duration_ms).saturating_sub(started.elapsed());
559- let gate = tokio::select! {
560- _ = cancel.changed() => return Err(Error::interrupted()),
561- result = matrix_sdk::timeout::timeout(context.repair_gate.clone().lock_owned(), remaining) =>
562- result.map_err(|_| Error::new(ErrorKind::RoomBusy, "repair work duration exhausted"))?,
563- };
564- if context.repair_shutdown.load(Ordering::Acquire) {
565- return Err(Error::interrupted());
566- }
567- let guard = SyncPause {
568- sync: context.sync.clone(),
569- shutdown: context.repair_shutdown.clone(),
570- gate: Some(gate),
571- };
572- context.sync.stop().await;
573- Ok(guard)
420+fn failed_report(
421+ room_id: ruma::OwnedRoomId,
422+ policy: HistoryRepairPolicy,
423+ error: Error,
424+) -> HistoryRepairReport {
425+ let mut report = report(room_id, policy);
426+ report.phase = HistoryRepairPhase::Paused;
427+ report.failure = Some(error);
428+ report
574429 }
575430
576431 fn cache_error(source: matrix_sdk::event_cache::EventCacheError) -> Error {
577- Error::with_source(
578- ErrorKind::Store,
579- "room history cache operation failed",
580- source,
581- )
432+ Error::with_source(ErrorKind::Store, "room history operation failed", source)
582433 }
583434
584-#[cfg(test)]
585-mod tests {
586- use super::*;
587-
588- #[test]
589- fn threshold_requires_known_coverage_and_an_independent_boundary() {
590- let policy = HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 100 };
591- assert!(should_bridge(&policy, Some(100), true));
592- assert!(should_bridge(&policy, Some(99), true));
593- assert!(!should_bridge(&policy, Some(101), true));
594- assert!(!should_bridge(&policy, None, true));
595- assert!(!should_bridge(&policy, Some(1), false));
596- assert!(!should_bridge(
597- &HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 0 },
598- Some(0),
599- true
600- ));
601- assert!(should_bridge(&HistoryRepairPolicy::Bridge, None, false));
602- assert!(!should_bridge(
603- &HistoryRepairPolicy::StartRecent,
604- Some(1),
605- true
606- ));
607- }
435+fn room_busy() -> Error {
436+ Error::new(
437+ ErrorKind::RoomBusy,
438+ "room history operation is already running",
439+ )
608440 }
Modified file: src/engine/room.rs+53 additions−92 deletions
@@ -6,11 +6,11 @@ use ruma::OwnedRoomId;
66 use tokio::sync::mpsc;
77
88 use super::executor::{AbortHandle, JoinError, TaskSet, is_panic, resume_unwind};
9-use super::repair::{InstallRequest, RepairController};
9+use super::repair::{Completion, RepairController};
1010 use super::timeline::{SessionContext, TaskGuard, TimelineSession};
1111 use crate::{
12- Error, ErrorKind, Event, HistoryRepairCommand, TimelineCommand, TimelineId, TypingState,
13- transport::Responder,
12+ Error, ErrorKind, Event, HistoryRepairCommand, RecentHistoryState, TimelineCommand, TimelineId,
13+ TypingState, transport::Responder,
1414 };
1515
1616 pub(super) enum ActorRequest {
@@ -93,9 +93,6 @@ impl RoomActor {
9393 else {
9494 return;
9595 };
96- for reply in self.opening.drain(..) {
97- reply.done(Ok(()));
98- }
9996 let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();
10097 let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(
10198 "typing watcher",
@@ -107,8 +104,14 @@ impl RoomActor {
107104 work: TaskSet::new(),
108105 };
109106 let mut repair = RepairController::new(self.room.clone(), self.context.clone());
107+ repair.start_recent().await;
108+ for reply in self.opening.drain(..) {
109+ reply.done(Ok(()));
110+ }
110111 let close = self.serve_open(&mut timelines, &mut repair).await;
111- repair.shutdown().await;
112+ if let Some(completion) = repair.shutdown().await {
113+ self.finish_repair(completion, &mut timelines).await;
114+ }
112115 if close.is_none() {
113116 self.requests.close();
114117 let _ = self.notes.send(ActorNote::Leaving {
@@ -137,6 +140,46 @@ impl RoomActor {
137140 }
138141 }
139142
143+ 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+ }
153+ }
154+ 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+ {
177+ resolved = Err(Error::interrupted());
178+ cancellation = Err(Error::interrupted());
179+ }
180+ completion.resolve(resolved, cancellation);
181+ }
182+
140183 /// returns the close request's responder, if a close is why we stopped
141184 async fn serve_open(
142185 &mut self,
@@ -167,9 +210,9 @@ impl RoomActor {
167210 repair.execute(command, reply).await;
168211 }
169212 },
170- install = repair.next_install(), if repair.is_running() => {
171- if let Some(install) = install {
172- timelines.install_history(install, &self.room, &self.context).await;
213+ completion = repair.next_completion(), if repair.is_running() => {
214+ if let Some(completion) = completion {
215+ self.finish_repair(completion, timelines).await;
173216 }
174217 }
175218 Some(built) = timelines.threads.builds.join_next(), if !timelines.threads.builds.is_empty() => {
@@ -240,88 +283,6 @@ struct Timelines {
240283 }
241284
242285 impl Timelines {
243- async fn install_history(
244- &mut self,
245- install: InstallRequest,
246- room: &Room,
247- context: &SessionContext,
248- ) {
249- let InstallRequest {
250- events,
251- prev_batch,
252- reply,
253- } = install;
254- let result = self
255- .install_history_inner(events, prev_batch, room, context)
256- .await;
257- let _ = reply.send(result);
258- }
259-
260- async fn install_history_inner(
261- &mut self,
262- events: Vec<matrix_sdk::deserialized_responses::TimelineEvent>,
263- prev_batch: Option<String>,
264- room: &Room,
265- context: &SessionContext,
266- ) -> Result<(), Error> {
267- let threads_capacity = self.threads.capacity;
268- self.threads.close().await;
269- self.threads = Threads::new(threads_capacity);
270- self.room.pause_for_reseed().await;
271-
272- let reseed = async {
273- let (cache, _drop_handles) = room.event_cache().await.map_err(|source| {
274- Error::with_source(ErrorKind::Timeline, "history event cache failed", source)
275- })?;
276- cache.reseed(events, prev_batch).await.map_err(|source| {
277- Error::with_source(ErrorKind::Timeline, "history cache reseed failed", source)
278- })
279- }
280- .await;
281- if let Err(error) = reseed {
282- return match self.room.resume_after_failed_reseed().await {
283- Ok(()) => Err(error),
284- Err(restart_error) => Err(restart_error),
285- };
286- }
287-
288- let timeline = match build_timeline(
289- room,
290- TimelineFocus::Live {
291- hide_threaded_events: true,
292- },
293- )
294- .await
295- {
296- Ok(timeline) => timeline,
297- Err(source) => {
298- let error =
299- Error::with_source(ErrorKind::Timeline, "room timeline rebuild failed", source);
300- return match self.room.resume_after_failed_reseed().await {
301- Ok(()) => Err(error),
302- Err(restart_error) => Err(restart_error),
303- };
304- }
305- };
306- let timeline_id = self.room.id.clone();
307- let mut context = context.clone();
308- context.initial_history_limit = 0;
309- let timeline = match TimelineSession::start(timeline_id, timeline, context).await {
310- Ok(timeline) => timeline,
311- Err(source) => {
312- let error =
313- Error::with_source(ErrorKind::Timeline, "room timeline reset failed", source);
314- return match self.room.resume_after_failed_reseed().await {
315- Ok(()) => Err(error),
316- Err(restart_error) => Err(restart_error),
317- };
318- }
319- };
320- let previous = std::mem::replace(&mut self.room, timeline);
321- drop(previous);
322- Ok(())
323- }
324-
325286 async fn execute(&mut self, command: TimelineCommand, reply: Responder, room: &Room) {
326287 match command {
327288 TimelineCommand::OpenThread {
Modified file: src/engine/timeline.rs+15 additions−23 deletions
@@ -12,10 +12,9 @@ use matrix_sdk::{
1212 },
1313 },
1414 };
15-use matrix_sdk_ui::sync_service::SyncService;
1615 use matrix_sdk_ui::timeline::{Timeline, TimelineEventItemId};
1716 use ruma::{OwnedEventId, OwnedUserId};
18-use tokio::sync::{Mutex, mpsc};
17+use tokio::sync::mpsc;
1918
2019 use super::executor::{EngineBoxFuture, JoinHandle, TaskSet, spawn};
2120 use crate::{
@@ -68,9 +67,6 @@ pub(super) struct SessionContext {
6867 pub(super) events: EventSender,
6968 pub(super) media: Arc<MediaService>,
7069 pub(super) mentions: crate::mentions::service::Handle,
71- pub(super) sync: Arc<SyncService>,
72- pub(super) repair_gate: Arc<Mutex<()>>,
73- pub(super) repair_shutdown: Arc<std::sync::atomic::AtomicBool>,
7470 pub(super) current_user_id: Option<OwnedUserId>,
7571 pub(super) initial_history_limit: u16,
7672 pub(super) supervisor: Supervisor,
@@ -118,7 +114,7 @@ impl TimelineSession {
118114 member_fetch,
119115 context,
120116 };
121- if session.context.initial_history_limit > 0 {
117+ if session.context.initial_history_limit > 0 && !matches!(session.id, TimelineId::Room(_)) {
122118 session.paginate(session.context.initial_history_limit, None);
123119 }
124120 Ok(session)
@@ -135,6 +131,19 @@ impl TimelineSession {
135131 Ok(())
136132 }
137133
134+ pub(super) async fn publish_history(&mut self) -> Result<(), Error> {
135+ self.timeline
136+ .flush_history()
137+ .await
138+ .map_err(timeline_error)?;
139+ self.resync().await?;
140+ self.context
141+ .events
142+ .publish_barrier()
143+ .await
144+ .map_err(|_| Error::interrupted())
145+ }
146+
138147 pub(super) fn paginate(&self, limit: u16, reply: Option<Responder>) {
139148 if let Err(error) = self.pagination.try_send(PaginationRequest { limit, reply }) {
140149 let (request, error) = match error {
@@ -153,10 +162,6 @@ impl TimelineSession {
153162 }
154163 }
155164
156- pub(super) async fn pause_for_reseed(&mut self) {
157- self.stop_without_close().await;
158- }
159-
160165 async fn stop_without_close(&mut self) {
161166 self.member_fetch.abort();
162167 self.pagination_worker.abort();
@@ -168,19 +173,6 @@ impl TimelineSession {
168173 self.context.media.release(&self.scope).await;
169174 }
170175
171- pub(super) async fn resume_after_failed_reseed(&mut self) -> Result<(), Error> {
172- self.watcher = start_watcher(&self.id, &self.timeline, &self.context, &self.scope)
173- .await
174- .map_err(|_| Error::new(ErrorKind::Timeline, "timeline reset could not be sent"))?;
175- (self.pagination, self.pagination_worker) =
176- start_pagination_worker(&self.id, &self.timeline, &self.context);
177- self.member_fetch = self.context.supervisor.spawn("member fetch", {
178- let timeline = self.timeline.clone();
179- async move { timeline.fetch_members().await }
180- });
181- Ok(())
182- }
183-
184176 /// pending paginations answer `Interrupted` as their responders drop
185177 pub(super) async fn close(mut self) {
186178 self.stop_without_close().await;
Modified file: src/lib.rs+10 additions−9 deletions
@@ -64,17 +64,18 @@ pub use model::{
6464 MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackCommand,
6565 PackImage, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile, PushNotification,
6666 PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
67- Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,
68- RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
69- RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
70- Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
71- TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
72- UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
73- VerificationFailure, VerificationState, VerificationTarget,
67+ Reaction, RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
68+ RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
69+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,
70+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand,
71+ TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,
72+ TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,
73+ VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
7474 };
7575 pub use repair_model::{
76- HistoryContinuity, HistoryRepairBudget, HistoryRepairCommand, HistoryRepairPhase,
77- HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop, HistoryRepairStrategy,
76+ HistoryContinuity, HistoryGap, HistoryGapLifecycle, HistoryRepairBudget, HistoryRepairCommand,
77+ HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairReport, HistoryRepairStop,
78+ HistoryRepairStrategy,
7879 };
7980 pub use ruma;
8081 #[doc(hidden)]
Modified file: src/mirror.rs+26 additions−3 deletions
@@ -20,9 +20,10 @@ use tokio::sync::broadcast;
2020
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23- HistoryRepairReport, HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff,
24- TimelineId, TimelineItem, TypingState, UploadOutcome, VerificationState, VerificationTarget,
25- engine::executor::EngineBoxFuture, transport::PublicationRegistration,
23+ HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState, RoomSummary,
24+ TimelineCommand, TimelineDiff, TimelineId, TimelineItem, TypingState, UploadOutcome,
25+ VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,
26+ transport::PublicationRegistration,
2627 };
2728
2829 #[cfg(not(target_family = "wasm"))]
@@ -49,6 +50,8 @@ pub struct State {
4950 pub rooms: Vector<RoomSummary>,
5051 pub image_packs: ImagePacks,
5152 pub timelines: imbl::HashMap<TimelineId, TimelineState>,
53+ #[cfg_attr(feature = "serde", serde(default))]
54+ pub recent_history: imbl::HashMap<OwnedRoomId, RecentHistoryState>,
5255 pub repairs: imbl::HashMap<OwnedRoomId, HistoryRepairReport>,
5356 pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,
5457 pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,
@@ -136,6 +139,10 @@ pub enum StateChange {
136139 timeline_id: TimelineId,
137140 state: HistoryState,
138141 },
142+ RecentHistory {
143+ room_id: OwnedRoomId,
144+ state: RecentHistoryState,
145+ },
139146 HistoryRepair(HistoryRepairReport),
140147 Typing(TypingState),
141148 Upload {
@@ -209,6 +216,7 @@ impl Mirror {
209216 rooms: Vector::new(),
210217 image_packs: ImagePacks::default(),
211218 timelines: imbl::HashMap::new(),
219+ recent_history: imbl::HashMap::new(),
212220 repairs: imbl::HashMap::new(),
213221 typing: imbl::HashMap::new(),
214222 uploads: imbl::HashMap::new(),
@@ -363,6 +371,16 @@ impl Mirror {
363371 state: history,
364372 })
365373 }),
374+ Event::RecentHistoryChanged {
375+ room_id,
376+ state: recent,
377+ } => self.publish(|state| {
378+ state.recent_history.insert(room_id.clone(), recent.clone());
379+ Some(StateChange::RecentHistory {
380+ room_id,
381+ state: recent,
382+ })
383+ }),
366384 Event::HistoryRepairChanged(report) => self.publish(|state| {
367385 state.repairs.insert(report.room_id.clone(), report.clone());
368386 Some(StateChange::HistoryRepair(report))
@@ -379,6 +397,11 @@ impl Mirror {
379397 acknowledgement.complete(true);
380398 }
381399 }
400+ Event::PublicationBarrier { acknowledgement } => {
401+ if let Some(acknowledgement) = acknowledgement {
402+ acknowledgement.complete(true);
403+ }
404+ }
382405 Event::TimelineClosed { timeline_id } => {
383406 self.cancel_resync(&timeline_id);
384407 self.publish(|state| {
Modified file: src/model.rs+19 additions
@@ -907,6 +907,10 @@ pub enum Event {
907907 timeline_id: TimelineId,
908908 state: HistoryState,
909909 },
910+ RecentHistoryChanged {
911+ room_id: OwnedRoomId,
912+ state: RecentHistoryState,
913+ },
910914 HistoryRepairChanged(crate::HistoryRepairReport),
911915 #[doc(hidden)]
912916 HistoryRepairPublished {
@@ -914,6 +918,11 @@ pub enum Event {
914918 #[cfg_attr(feature = "serde", serde(skip))]
915919 acknowledgement: Option<crate::PublicationAck>,
916920 },
921+ #[doc(hidden)]
922+ PublicationBarrier {
923+ #[cfg_attr(feature = "serde", serde(skip))]
924+ acknowledgement: Option<crate::PublicationAck>,
925+ },
917926 /// the timeline was closed, or its room was left, and nothing more comes for it
918927 /// until another `TimelineReset`
919928 TimelineClosed {
@@ -1376,6 +1385,7 @@ pub struct OwnDevice {
13761385 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13771386 pub enum TimelineItem {
13781387 Event(Box<TimelineEvent>),
1388+ HistoryGap(crate::HistoryGap),
13791389 DayDivider { timestamp_ms: u64 },
13801390 ReadMarker,
13811391 TimelineStart,
@@ -2111,6 +2121,15 @@ pub enum HistoryState {
21112121
21122122 #[derive(Clone, Debug, Eq, PartialEq)]
21132123 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
2124+pub enum RecentHistoryState {
2125+ Idle,
2126+ Loading,
2127+ Loaded,
2128+ Failed(String),
2129+}
2130+
2131+#[derive(Clone, Debug, Eq, PartialEq)]
2132+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21142133 #[non_exhaustive]
21152134 pub struct EncryptionState {
21162135 pub device_id: OwnedDeviceId,
Modified file: src/normalise/timeline.rs+39 additions−3 deletions
@@ -19,9 +19,10 @@ use ruma::{
1919 };
2020
2121 use crate::{
22- Change, MediaId, MembershipChange, Message, MessageKind, MessageMentions, Reaction,
23- ReplyDetails, ReplyReference, SendState, Sender, ThreadSummary, TimelineContent, TimelineDiff,
24- TimelineEvent, TimelineItem, TimelineItemId, UtdCause,
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,
2526 };
2627
2728 use super::link_preview::sender_previews;
@@ -68,6 +69,41 @@ impl Normaliser<'_> {
6869 TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
6970 TimelineItem::TimelineStart
7071 }
72+ 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)
96+ }
97+ };
98+ TimelineItem::HistoryGap(HistoryGap {
99+ id: gap.id.clone(),
100+ older_boundary: gap.older_boundary.clone(),
101+ newer_boundary: gap.newer_boundary.clone(),
102+ lifecycle,
103+ continuity,
104+ failure,
105+ })
106+ }
71107 }
72108 }
73109
Modified file: src/repair_model.rs+34 additions−6 deletions
@@ -1,11 +1,11 @@
1-use ruma::OwnedRoomId;
1+use ruma::{OwnedEventId, OwnedRoomId, OwnedTransactionId};
22
33 #[cfg(feature = "serde")]
44 use serde::{Deserialize, Serialize};
55
66 use crate::Error;
77
8-#[derive(Clone, Debug, PartialEq)]
8+#[derive(Clone, Debug, PartialEq, Eq)]
99 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1010 pub enum HistoryRepairPolicy {
1111 StartRecent,
@@ -30,8 +30,8 @@ pub struct HistoryRepairBudget {
3030 impl Default for HistoryRepairBudget {
3131 fn default() -> Self {
3232 Self {
33- max_pages: 10,
34- max_events: 1000,
33+ max_pages: 4,
34+ max_events: 200,
3535 max_duration_ms: 15_000,
3636 }
3737 }
@@ -55,11 +55,36 @@ pub enum HistoryRepairCommand {
5555 StartRecent {
5656 room_id: OwnedRoomId,
5757 },
58+ FillGap {
59+ room_id: OwnedRoomId,
60+ gap_id: OwnedTransactionId,
61+ },
5862 GetPolicy,
5963 SetPolicy(HistoryRepairPolicy),
6064 }
6165
62-#[derive(Clone, Debug, PartialEq)]
66+#[derive(Clone, Debug, PartialEq, Eq)]
67+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
68+pub struct HistoryGap {
69+ pub id: OwnedTransactionId,
70+ pub older_boundary: Option<OwnedEventId>,
71+ pub newer_boundary: Option<OwnedEventId>,
72+ pub lifecycle: HistoryGapLifecycle,
73+ pub continuity: HistoryContinuity,
74+ pub failure: Option<String>,
75+}
76+
77+#[derive(Clone, Debug, PartialEq, Eq)]
78+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
79+pub enum HistoryGapLifecycle {
80+ Idle,
81+ Fetching,
82+ Paused,
83+ Failed,
84+ Unavailable,
85+}
86+
87+#[derive(Clone, Debug, PartialEq, Eq)]
6388 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
6489 pub enum HistoryRepairStrategy {
6590 Recent,
@@ -67,7 +92,7 @@ pub enum HistoryRepairStrategy {
6792 Bridge,
6893 }
6994
70-#[derive(Clone, Debug, PartialEq)]
95+#[derive(Clone, Debug, PartialEq, Eq)]
7196 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
7297 pub enum HistoryContinuity {
7398 Unresolved,
@@ -98,6 +123,8 @@ pub enum HistoryRepairStop {
98123 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99124 pub struct HistoryRepairReport {
100125 pub room_id: OwnedRoomId,
126+ #[cfg_attr(feature = "serde", serde(default))]
127+ pub gap_id: Option<OwnedTransactionId>,
101128 pub requested_policy: HistoryRepairPolicy,
102129 pub strategy: HistoryRepairStrategy,
103130 pub coverage_duration_ms: Option<u64>,
@@ -132,6 +159,7 @@ mod tests {
132159 assert_eq!(decoded_command, command);
133160
134161 let report = HistoryRepairReport {
162+ gap_id: None,
135163 room_id: room_id!("!room:example.org").to_owned(),
136164 requested_policy: HistoryRepairPolicy::Bridge,
137165 strategy: HistoryRepairStrategy::Overlap,
Modified file: src/transport.rs+33 additions
@@ -375,6 +375,18 @@ impl ClientHandle {
375375 .await
376376 }
377377
378+ pub async fn fill_history_gap(
379+ &self,
380+ room_id: OwnedRoomId,
381+ gap_id: ruma::OwnedTransactionId,
382+ ) -> Result<(), Error> {
383+ self.run(Command::HistoryRepair(HistoryRepairCommand::FillGap {
384+ room_id,
385+ gap_id,
386+ }))
387+ .await
388+ }
389+
378390 pub async fn start_recent_history(
379391 &self,
380392 room_id: OwnedRoomId,
@@ -1620,6 +1632,26 @@ impl EventSender {
16201632 self.events.send(event).await.map_err(|_| SendEventError)
16211633 }
16221634
1635+ pub(crate) async fn publish_barrier(&self) -> Result<(), SendEventError> {
1636+ let Some((acknowledgement, published)) = self.publication.acknowledgement() else {
1637+ return Ok(());
1638+ };
1639+ if let Err(error) = self
1640+ .send(Event::PublicationBarrier {
1641+ acknowledgement: Some(acknowledgement.clone()),
1642+ })
1643+ .await
1644+ {
1645+ acknowledgement.complete(false);
1646+ return Err(error);
1647+ }
1648+ drop(acknowledgement);
1649+ match published.await {
1650+ Ok(true) => Ok(()),
1651+ Ok(false) | Err(_) => Err(SendEventError),
1652+ }
1653+ }
1654+
16231655 pub(crate) async fn publish_repair(
16241656 &self,
16251657 report: crate::HistoryRepairReport,
@@ -1812,6 +1844,7 @@ mod tests {
18121844
18131845 fn report() -> HistoryRepairReport {
18141846 HistoryRepairReport {
1847+ gap_id: None,
18151848 room_id: room_id!("!room:example.org").to_owned(),
18161849 requested_policy: HistoryRepairPolicy::Bridge,
18171850 strategy: HistoryRepairStrategy::Bridge,
Modified file: tests/engine.rs+18 additions−8 deletions
@@ -4,7 +4,8 @@ use std::time::Duration;
44
55 use macltok::{
66 ClientHandle, Command, Engine, EngineConfig, ErrorKind, Event, EventStream, HistoryState,
7- MediaSize, MessageContent, RoomCommand, TimelineCommand, TimelineId, TimelineItem,
7+ MediaSize, MessageContent, RecentHistoryState, RoomCommand, TimelineCommand, TimelineId,
8+ TimelineItem,
89 };
910 use matrix_sdk::{
1011 ruma::{api::client::sync::sync_events::v5, event_id, mxc_uri, room_id},
@@ -315,9 +316,9 @@ async fn closes_and_reopens_a_room_in_command_order() {
315316 .try_send(Command::Room(RoomCommand::Open(room_id.to_owned())))
316317 .unwrap();
317318
318- // biased select drains every buffered reset before it ever reports a ready reply,
319- // so the engine can race ahead and land both resets before either open is observed
319+ // biased select drains buffered events before it reports ready replies
320320 let mut resets = 0;
321+ let mut recent_active = false;
321322 let mut order = Vec::new();
322323 let (mut close_done, mut replacement_open_done, mut second_close_done, mut final_open_done) =
323324 (false, false, false, false);
@@ -326,11 +327,20 @@ async fn closes_and_reopens_a_room_in_command_order() {
326327 tokio::select! {
327328 biased;
328329 event = events.recv() => {
329- if let Event::TimelineReset { timeline_id, .. } = event.expect("event stream closed")
330- && timeline_id.room_id() == room_id
331- {
332- resets += 1;
333- assert!(resets <= 2, "a room generation reset more than once");
330+ 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+ {
340+ resets += 1;
341+ assert!(resets <= 2, "a room generation reset more than once");
342+ }
343+ _ => {}
334344 }
335345 }
336346 result = &mut close, if !close_done => {
Modified file: tests/history_repair.rs+319 additions−381 deletions
@@ -3,9 +3,8 @@ mod support;
33 use std::time::Duration;
44
55 use macltok::{
6- ClientHandle, Engine, EngineConfig, EngineError, Event, EventStream, HistoryContinuity,
7- HistoryRepairBudget, HistoryRepairPhase, HistoryRepairPolicy, HistoryRepairStop,
8- HistoryRepairStrategy, Termination, TimelineItem,
6+ ClientHandle, Engine, EngineConfig, EngineError, Event, EventStream, HistoryGap,
7+ HistoryRepairPhase, HistoryRepairPolicy, RecentHistoryState, Termination, TimelineItem,
98 };
109 #[cfg(feature = "mirror")]
1110 use macltok::{Mirror, TimelineId};
@@ -26,7 +25,7 @@ struct TestRoom {
2625 running: tokio::task::JoinHandle<Result<Termination, EngineError>>,
2726 }
2827
29-async fn open_room(room_id: &RoomId) -> TestRoom {
28+async fn start_room(room_id: &RoomId) -> TestRoom {
3029 let server = MatrixMockServer::new().await;
3130 server
3231 .mock_sliding_sync()
@@ -38,15 +37,8 @@ async fn open_room(room_id: &RoomId) -> TestRoom {
3837 server.sync_joined_room(&client, room_id).await;
3938 let sync_client = client.clone();
4039 let config = EngineConfig::default().with_initial_history_limit(0);
41- let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
40+ let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();
4241 let running = tokio::spawn(engine.run());
43-
44- handle.open_room(room_id.to_owned()).await.unwrap();
45- receive_until(&mut events, |event| {
46- matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
47- })
48- .await;
49-
5042 TestRoom {
5143 server,
5244 sync_client,
@@ -56,7 +48,15 @@ async fn open_room(room_id: &RoomId) -> TestRoom {
5648 }
5749 }
5850
59-async fn sync_cached_event(room: &mut TestRoom, room_id: &RoomId, id: &EventId, body: &str) {
51+async fn open_room(room: &mut TestRoom, room_id: &RoomId) {
52+ room.handle.open_room(room_id.to_owned()).await.unwrap();
53+ receive_until(&mut room.events, |event| {
54+ matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
55+ })
56+ .await;
57+}
58+
59+async fn cache_event_before_open(room: &TestRoom, room_id: &RoomId, id: &EventId, body: &str) {
6060 room.server
6161 .sync_room(
6262 &room.sync_client,
@@ -68,12 +68,6 @@ async fn sync_cached_event(room: &mut TestRoom, room_id: &RoomId, id: &EventId,
6868 ),
6969 )
7070 .await;
71- receive_until(&mut room.events, |event| {
72- event_messages(event)
73- .into_iter()
74- .any(|event| event.event_id.as_deref() == Some(id))
75- })
76- .await;
7771 }
7872
7973 fn history_event(
@@ -89,94 +83,178 @@ fn history_event(
8983 .into_raw_timeline()
9084 }
9185
92-fn report_budget(max_pages: u16) -> HistoryRepairBudget {
93- HistoryRepairBudget {
94- max_pages,
95- ..HistoryRepairBudget::default()
86+async fn wait_for_recent(room: &mut TestRoom, room_id: &RoomId) -> RecentHistoryState {
87+ let event = receive_until(&mut room.events, |event| {
88+ matches!(event, Event::RecentHistoryChanged { room_id: id, state, .. }
89+ if id == room_id && !matches!(state, RecentHistoryState::Loading))
90+ })
91+ .await;
92+ match event {
93+ Event::RecentHistoryChanged { state, .. } => state,
94+ _ => unreachable!(),
95+ }
96+}
97+
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+ )
103+ .await
104+ {
105+ Event::HistoryRepairChanged(report) => report,
106+ _ => unreachable!(),
96107 }
97108 }
98109
110+fn gap_in(items: &imbl::Vector<TimelineItem>) -> Option<HistoryGap> {
111+ items.iter().find_map(|item| match item {
112+ TimelineItem::HistoryGap(gap) => Some(gap.clone()),
113+ _ => None,
114+ })
115+}
116+
99117 #[tokio::test]
100-async fn recent_repair_starts_without_a_cursor_and_keeps_its_end_for_pagination() {
118+async fn automatic_recent_load_is_bounded_and_keeps_cached_history_behind_a_gap() {
101119 let room_id = room_id!("!recent-repair:localhost");
102- let stale_id = event_id!("$stale:localhost");
103- let fresh_id = event_id!("$fresh:localhost");
104- let older_id = event_id!("$older:localhost");
105- let mut room = open_room(room_id).await;
106- sync_cached_event(&mut room, room_id, stale_id, "cached before repair").await;
107-
108- room.server
109- .mock_room_messages()
110- .ok(RoomMessagesResponseTemplate::default()
111- .end_token("recent-prev")
112- .events(vec![history_event(room_id, fresh_id, "fresh page")]))
113- .mock_once()
114- .mount()
115- .await;
116- let report = room
117- .handle
118- .history_repair(
119- room_id.to_owned(),
120- Some(HistoryRepairPolicy::StartRecent),
121- HistoryRepairBudget::default(),
122- )
120+ let old_id = event_id!("$old:localhost");
121+ let new_ids = [
122+ event_id!("$fresh-a:localhost"),
123+ event_id!("$fresh-b:localhost"),
124+ event_id!("$fresh-c:localhost"),
125+ event_id!("$fresh-d:localhost"),
126+ ];
127+ let mut room = start_room(room_id).await;
128+ room.handle
129+ .set_history_repair_policy(HistoryRepairPolicy::Bridge)
123130 .await
124131 .unwrap();
125- assert_eq!(report.strategy, HistoryRepairStrategy::Recent);
126- assert_eq!(report.phase, HistoryRepairPhase::Ready);
127- assert!(report.recent_available);
128-
132+ for (index, id) in new_ids.iter().enumerate() {
133+ let token = format!("recent-{index}");
134+ let response = RoomMessagesResponseTemplate::default()
135+ .end_token(token.clone())
136+ .events(vec![history_event(room_id, id, "recent event")]);
137+ if index > 0 {
138+ let previous = format!("recent-{}", index - 1);
139+ room.server
140+ .mock_room_messages()
141+ .match_from(&previous)
142+ .ok(response)
143+ .mock_once()
144+ .mount()
145+ .await;
146+ } else {
147+ room.server
148+ .mock_room_messages()
149+ .ok(response)
150+ .mock_once()
151+ .mount()
152+ .await;
153+ }
154+ }
155+ cache_event_before_open(&room, room_id, old_id, "retained old event").await;
156+ open_room(&mut room, room_id).await;
129157 let reset = receive_until(&mut room.events, |event| {
130- matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
131- && event_messages(event)
132- .into_iter()
133- .any(|event| event.event_id.as_deref() == Some(fresh_id))
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+ };
164+ 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+ })
134175 })
135176 .await;
136- let reset_ids = event_messages(&reset)
137- .into_iter()
138- .filter_map(|event| event.event_id.as_ref().map(ToString::to_string))
139- .collect::<Vec<_>>();
140- assert_eq!(reset_ids, vec![fresh_id.to_string()]);
141-
177+ let Event::TimelineReset { items, .. } = reset else {
178+ unreachable!();
179+ };
180+ assert!(gap_in(&items).is_some());
181+ assert_eq!(
182+ wait_for_recent(&mut room, room_id).await,
183+ RecentHistoryState::Loaded
184+ );
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);
142191 let requests = room.server.received_requests().await.unwrap();
143192 let messages = requests
144193 .iter()
145194 .filter(|request| request.url.path().ends_with("/messages"))
146195 .collect::<Vec<_>>();
147- assert_eq!(messages.len(), 1);
196+ assert_eq!(messages.len(), 4);
148197 assert!(!messages[0].url.query_pairs().any(|(key, _)| key == "from"));
198+ shut_down(&room.handle, &mut room.events, room.running).await;
199+}
149200
201+#[tokio::test]
202+async fn explicit_recent_start_coalesces_with_the_automatic_load() {
203+ let room_id = room_id!("!recent-coalesce:localhost");
204+ let fresh_id = event_id!("$coalesced:localhost");
205+ let mut room = start_room(room_id).await;
150206 room.server
151207 .mock_room_messages()
152- .match_from("recent-prev")
153- .ok(
154- RoomMessagesResponseTemplate::default().events(vec![history_event(
155- room_id,
156- older_id,
157- "older page",
158- )]),
159- )
208+ .ok(RoomMessagesResponseTemplate::default()
209+ .with_delay(Duration::from_millis(300))
210+ .events(vec![history_event(room_id, fresh_id, "shared result")]))
160211 .mock_once()
161212 .mount()
162213 .await;
163- room.handle
164- .paginate(room_id.to_owned().into(), 10)
165- .await
166- .unwrap();
167- receive_until(&mut room.events, |event| {
168- event_messages(event).into_iter().any(|event| {
169- support::message(event).is_some_and(|message| message.body == "older page")
170- })
214+ open_room(&mut room, room_id).await;
215+ tokio::time::timeout(Duration::from_secs(5), async {
216+ loop {
217+ let requested = room
218+ .server
219+ .received_requests()
220+ .await
221+ .unwrap()
222+ .iter()
223+ .any(|request| request.url.path().ends_with("/messages"));
224+ if requested {
225+ break;
226+ }
227+ tokio::task::yield_now().await;
228+ }
171229 })
172- .await;
230+ .await
231+ .expect("automatic recent request did not start");
173232
233+ let report = room
234+ .handle
235+ .start_recent_history(room_id.to_owned())
236+ .await
237+ .unwrap();
238+ assert_eq!(report.pages, 1);
239+ assert_eq!(report.events, 1);
240+ assert_eq!(
241+ wait_for_recent(&mut room, room_id).await,
242+ RecentHistoryState::Loaded
243+ );
244+ let requests = room.server.received_requests().await.unwrap();
245+ assert_eq!(
246+ requests
247+ .iter()
248+ .filter(|request| request.url.path().ends_with("/messages"))
249+ .count(),
250+ 1
251+ );
174252 shut_down(&room.handle, &mut room.events, room.running).await;
175253 }
176254
177255 #[cfg(feature = "mirror")]
178256 #[tokio::test]
179-async fn repair_ack_waits_until_the_mirror_has_applied_the_reset_and_report() {
257+async fn fill_gap_returns_after_the_mirror_contains_the_committed_rows() {
180258 let server = MatrixMockServer::new().await;
181259 server
182260 .mock_sliding_sync()
@@ -184,10 +262,13 @@ async fn repair_ack_waits_until_the_mirror_has_applied_the_reset_and_report() {
184262 .mount()
185263 .await;
186264 let client = server.client_builder().build().await;
187- let room_id = room_id!("!repair-mirror:localhost");
265+ let room_id = room_id!("!fill-gap-mirror:localhost");
188266 server.mock_room_state_encryption().plain().mount().await;
189267 server.sync_joined_room(&client, room_id).await;
190268 let sync_client = client.clone();
269+ let old_id = event_id!("$gap-old:localhost");
270+ let fresh_id = event_id!("$gap-fresh:localhost");
271+ let middle_id = event_id!("$gap-middle:localhost");
191272 let (engine, handle, events) = Engine::from_client(
192273 client,
193274 EngineConfig::default().with_initial_history_limit(0),
@@ -197,79 +278,114 @@ async fn repair_ack_waits_until_the_mirror_has_applied_the_reset_and_report() {
197278 let running = tokio::spawn(engine.run());
198279 let (mirror, mirror_handle) = Mirror::new(handle.clone(), events);
199280 let mirror_running = tokio::spawn(mirror.run());
200-
201- handle.open_room(room_id.to_owned()).await.unwrap();
202- let stale_id = event_id!("$mirror-stale:localhost");
281+ server
282+ .mock_room_messages()
283+ .ok(RoomMessagesResponseTemplate::default()
284+ .end_token("recent-gap")
285+ .events(vec![history_event(room_id, fresh_id, "new window")]))
286+ .mock_once()
287+ .mount()
288+ .await;
289+ server
290+ .mock_room_messages()
291+ .match_from("recent-gap")
292+ .ok(RoomMessagesResponseTemplate::default().end_token("recent-gap"))
293+ .mock_once()
294+ .mount()
295+ .await;
203296 server
204297 .sync_room(
205298 &sync_client,
206299 JoinedRoomBuilder::new(room_id).add_timeline_event(
207300 EventFactory::new()
208- .text_msg("cached event")
209- .event_id(stale_id)
301+ .text_msg("old retained")
302+ .event_id(old_id)
210303 .sender(&ALICE),
211304 ),
212305 )
213306 .await;
307+ handle.open_room(room_id.to_owned()).await.unwrap();
214308 let timeline_id = TimelineId::from(room_id.to_owned());
215- tokio::time::timeout(Duration::from_secs(2), async {
309+ let gap = tokio::time::timeout(Duration::from_secs(5), async {
216310 loop {
217- let has_cached_event = mirror_handle
311+ if let Some(gap) = mirror_handle
218312 .snapshot()
219313 .timelines
220314 .get(&timeline_id)
221- .is_some_and(|timeline| {
222- timeline.items.iter().any(|item| {
223- matches!(item, TimelineItem::Event(event)
224- if event.event_id.as_deref() == Some(stale_id))
225- })
226- });
227- if has_cached_event {
315+ .and_then(|timeline| gap_in(&timeline.items))
316+ {
317+ break gap;
318+ }
319+ tokio::task::yield_now().await;
320+ }
321+ })
322+ .await
323+ .expect("the recent history gap did not reach the mirror");
324+ tokio::time::timeout(Duration::from_secs(5), async {
325+ loop {
326+ if mirror_handle.snapshot().recent_history.get(room_id)
327+ == Some(&RecentHistoryState::Loaded)
328+ {
228329 break;
229330 }
230331 tokio::task::yield_now().await;
231332 }
232333 })
233334 .await
234- .expect("the cached timeline event did not reach the mirror");
235-
236- let fresh_id = event_id!("$mirror-fresh:localhost");
335+ .expect("automatic recent loading did not finish");
237336 server
238337 .mock_room_messages()
239- .ok(
240- RoomMessagesResponseTemplate::default().events(vec![history_event(
241- room_id,
242- fresh_id,
243- "fresh event",
244- )]),
245- )
338+ .match_from("recent-gap")
339+ .ok(RoomMessagesResponseTemplate::default()
340+ .with_delay(Duration::from_millis(300))
341+ .events(vec![
342+ history_event(room_id, middle_id, "middle page"),
343+ history_event(room_id, old_id, "old retained overlap"),
344+ ]))
246345 .mock_once()
247346 .mount()
248347 .await;
249- let report = handle
250- .history_repair(
251- room_id.to_owned(),
252- Some(HistoryRepairPolicy::StartRecent),
253- HistoryRepairBudget::default(),
254- )
255- .await
256- .unwrap();
257348
258- let state = mirror_handle.snapshot();
259- assert_eq!(state.repairs.get(room_id), Some(&report));
260- let timeline = state
349+ let fill_handle = handle.clone();
350+ let target_gap_id = gap.id.clone();
351+ let fill_gap_id = target_gap_id.clone();
352+ let fill = tokio::spawn(async move {
353+ fill_handle
354+ .fill_history_gap(room_id.to_owned(), fill_gap_id)
355+ .await
356+ });
357+ tokio::time::timeout(Duration::from_secs(5), async {
358+ loop {
359+ let snapshot = mirror_handle.snapshot();
360+ 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
363+ });
364+ if active_gap {
365+ break;
366+ }
367+ tokio::task::yield_now().await;
368+ }
369+ })
370+ .await
371+ .expect("the active repair report did not identify its target gap");
372+ fill.await.unwrap().unwrap();
373+ let snapshot = mirror_handle.snapshot();
374+ let timeline = snapshot
261375 .timelines
262376 .get(&timeline_id)
263- .expect("the repaired timeline was not applied before acknowledgement");
264- let ids = timeline
377+ .expect("the room timeline disappeared");
378+ let event_ids = timeline
265379 .items
266380 .iter()
267381 .filter_map(|item| match item {
268- TimelineItem::Event(event) => event.event_id.as_ref().map(ToString::to_string),
382+ TimelineItem::Event(event) => event.event_id.as_deref(),
269383 _ => None,
270384 })
271385 .collect::<Vec<_>>();
272- assert_eq!(ids, vec![fresh_id.to_string()]);
386+ assert!(event_ids.contains(&fresh_id));
387+ assert!(event_ids.contains(&middle_id));
388+ assert!(event_ids.contains(&old_id));
273389
274390 handle.shutdown().await.unwrap();
275391 assert_eq!(running.await.unwrap().unwrap(), Termination::ShutDown);
@@ -277,90 +393,69 @@ async fn repair_ack_waits_until_the_mirror_has_applied_the_reset_and_report() {
277393 }
278394
279395 #[tokio::test]
280-async fn failed_recent_fetch_leaves_the_existing_timeline_in_place() {
281- let room_id = room_id!("!failed-repair:localhost");
282- let stale_id = event_id!("$kept:localhost");
283- let mut room = open_room(room_id).await;
284- sync_cached_event(&mut room, room_id, stale_id, "keep this event").await;
285-
396+async fn cancelling_a_gap_fill_keeps_committed_progress_for_retry() {
397+ let room_id = room_id!("!cancel-gap-fill:localhost");
398+ let old_id = event_id!("$cancel-gap-old:localhost");
399+ let fresh_id = event_id!("$cancel-gap-fresh:localhost");
400+ let middle_id = event_id!("$cancel-gap-middle:localhost");
401+ let mut room = start_room(room_id).await;
286402 room.server
287403 .mock_room_messages()
288- .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
289- "errcode": "M_FORBIDDEN",
290- "error": "history is unavailable",
291- })))
404+ .ok(RoomMessagesResponseTemplate::default()
405+ .end_token("gap-cursor-1")
406+ .events(vec![history_event(room_id, fresh_id, "recent")]))
292407 .mock_once()
293408 .mount()
294409 .await;
295- let report = room
296- .handle
297- .history_repair(
298- room_id.to_owned(),
299- Some(HistoryRepairPolicy::StartRecent),
300- HistoryRepairBudget::default(),
301- )
302- .await
303- .unwrap();
304- assert_eq!(report.phase, HistoryRepairPhase::Paused);
305- assert!(report.failure.is_some());
306-
307- let mut reset_after_failure = false;
308- while let Ok(event) = room.events.try_recv() {
309- reset_after_failure |= matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id);
310- }
311- assert!(!reset_after_failure);
312-
313- shut_down(&room.handle, &mut room.events, room.running).await;
314-}
315-
316-#[tokio::test]
317-async fn cancelling_a_delayed_bridge_page_preserves_the_recent_stage() {
318- let room_id = room_id!("!cancel-repair:localhost");
319- let anchor_id = event_id!("$cancel-anchor:localhost");
320- let recent_id = event_id!("$cancel-recent:localhost");
321- let mut room = open_room(room_id).await;
322- sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
323-
324410 room.server
325411 .mock_room_messages()
326- .ok(RoomMessagesResponseTemplate::default()
327- .end_token("cancel-prev")
328- .events(vec![history_event(room_id, recent_id, "recent")]))
412+ .match_from("gap-cursor-1")
413+ .ok(RoomMessagesResponseTemplate::default().end_token("gap-cursor-1"))
329414 .mock_once()
330415 .mount()
331416 .await;
332- let initial = room
333- .handle
334- .history_repair(
335- room_id.to_owned(),
336- Some(HistoryRepairPolicy::Bridge),
337- report_budget(1),
338- )
339- .await
340- .unwrap();
341- assert_eq!(initial.stop, Some(HistoryRepairStop::Budget));
342- assert!(initial.recent_available);
343- receive_until(&mut room.events, |event| {
344- matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
345- && event_messages(event)
346- .into_iter()
347- .any(|event| event.event_id.as_deref() == Some(recent_id))
417+ cache_event_before_open(&room, room_id, old_id, "old cached").await;
418+ 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())
348422 })
349423 .await;
350-
424+ let gap = match reset {
425+ Event::TimelineReset { items, .. } => gap_in(&items).unwrap(),
426+ _ => unreachable!(),
427+ };
428+ assert_eq!(
429+ wait_for_recent(&mut room, room_id).await,
430+ RecentHistoryState::Loaded
431+ );
351432 room.server
352433 .mock_room_messages()
353- .match_from("cancel-prev")
434+ .match_from("gap-cursor-1")
435+ .ok(RoomMessagesResponseTemplate::default()
436+ .end_token("gap-cursor-2")
437+ .events(vec![history_event(
438+ room_id,
439+ middle_id,
440+ "committed progress",
441+ )]))
442+ .mock_once()
443+ .mount()
444+ .await;
445+ room.server
446+ .mock_room_messages()
447+ .match_from("gap-cursor-2")
354448 .ok(RoomMessagesResponseTemplate::default()
355449 .with_delay(Duration::from_secs(5))
356- .events(vec![history_event(room_id, anchor_id, "cached anchor")]))
450+ .events(vec![history_event(room_id, old_id, "old overlap")]))
357451 .mock_once()
358452 .mount()
359453 .await;
360- let repair_handle = room.handle.clone();
361- let repair = tokio::spawn(async move {
362- repair_handle
363- .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())
454+ let fill_handle = room.handle.clone();
455+ let gap_id = gap.id.clone();
456+ let fill = tokio::spawn(async move {
457+ fill_handle
458+ .fill_history_gap(room_id.to_owned(), gap_id)
364459 .await
365460 });
366461 tokio::time::timeout(Duration::from_secs(5), async {
@@ -376,7 +471,7 @@ async fn cancelling_a_delayed_bridge_page_preserves_the_recent_stage() {
376471 && request
377472 .url
378473 .query_pairs()
379- .any(|(key, value)| key == "from" && value == "cancel-prev")
474+ .any(|(key, value)| key == "from" && value == "gap-cursor-2")
380475 });
381476 if requested {
382477 break;
@@ -385,227 +480,70 @@ async fn cancelling_a_delayed_bridge_page_preserves_the_recent_stage() {
385480 }
386481 })
387482 .await
388- .expect("bridge request did not start");
483+ .expect("the second gap page did not start");
389484 room.handle
390485 .cancel_history_repair(room_id.to_owned())
391486 .await
392487 .unwrap();
393- let report = repair.await.unwrap().unwrap();
394- assert_eq!(report.phase, HistoryRepairPhase::Paused);
395- assert_eq!(report.stop, Some(HistoryRepairStop::Cancelled));
396- assert!(report.recent_available);
397- assert_eq!(report.pages, 1);
398-
399- let mut reset_after_cancel = false;
400- while let Ok(event) = room.events.try_recv() {
401- reset_after_cancel |= matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id);
402- }
403- assert!(!reset_after_cancel);
404-
405- shut_down(&room.handle, &mut room.events, room.running).await;
406-}
407-
408-#[tokio::test]
409-async fn bridge_reaches_the_cached_anchor_deduplicates_overlap_and_can_continue_after_budget() {
410- let room_id = room_id!("!bridge-repair:localhost");
411- let anchor_id = event_id!("$anchor:localhost");
412- let middle_id = event_id!("$middle:localhost");
413- let recent_id = event_id!("$recent:localhost");
414- let latest_id = event_id!("$latest:localhost");
415- let mut room = open_room(room_id).await;
416- sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
488+ let error = fill.await.unwrap().unwrap_err();
489+ assert_eq!(error.kind, macltok::ErrorKind::Interrupted);
417490
418491 room.server
419492 .mock_room_messages()
420- .ok(RoomMessagesResponseTemplate::default()
421- .end_token("recent-prev")
422- .events(vec![
423- history_event(room_id, recent_id, "recent"),
424- history_event(room_id, middle_id, "middle"),
425- ]))
426- .mock_once()
427- .mount()
428- .await;
429- let initial = room
430- .handle
431- .history_repair(
432- room_id.to_owned(),
433- Some(HistoryRepairPolicy::Bridge),
434- report_budget(1),
493+ .match_from("gap-cursor-2")
494+ .ok(
495+ RoomMessagesResponseTemplate::default().events(vec![history_event(
496+ room_id,
497+ old_id,
498+ "old overlap",
499+ )]),
435500 )
436- .await
437- .unwrap();
438- assert_eq!(initial.phase, HistoryRepairPhase::Paused);
439- assert_eq!(initial.stop, Some(HistoryRepairStop::Budget));
440- assert!(initial.recent_available);
441-
442- wiremock::Mock::given(|request: &wiremock::Request| {
443- request.url.path().ends_with("/messages")
444- && !request.url.query_pairs().any(|(key, _)| key == "from")
445- })
446- .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
447- "start": "head-start",
448- "chunk": [
449- history_event(room_id, latest_id, "latest"),
450- history_event(room_id, recent_id, "recent overlap"),
451- history_event(room_id, middle_id, "middle overlap"),
452- ],
453- "state": [],
454- })))
455- .up_to_n_times(1)
456- .expect(1)
457- .mount(room.server.server())
458- .await;
459- room.server
460- .mock_room_messages()
461- .match_from("recent-prev")
462- .ok(RoomMessagesResponseTemplate::default()
463- .end_token("older-prev")
464- .events(vec![
465- history_event(room_id, middle_id, "middle overlap"),
466- history_event(room_id, anchor_id, "cached anchor overlap"),
467- ]))
468501 .mock_once()
469502 .mount()
470503 .await;
471-
472- let report = room
473- .handle
474- .continue_history_repair(room_id.to_owned(), HistoryRepairBudget::default())
504+ room.handle
505+ .fill_history_gap(room_id.to_owned(), gap.id)
475506 .await
476507 .unwrap();
477- assert_eq!(report.strategy, HistoryRepairStrategy::Bridge);
478- assert_eq!(report.phase, HistoryRepairPhase::Ready, "{report:?}");
479- assert_eq!(report.continuity, HistoryContinuity::Verified);
480- assert_eq!(report.pages, 3);
481-
482- let reset = receive_until(&mut room.events, |event| {
483- matches!(event, Event::TimelineReset { timeline_id, .. } if timeline_id.room_id() == room_id)
484- && event_messages(event)
485- .into_iter()
486- .any(|event| event.event_id.as_deref() == Some(latest_id))
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))))
487515 })
488516 .await;
489- let reset_ids = event_messages(&reset)
490- .into_iter()
491- .filter_map(|event| event.event_id.as_ref().map(ToString::to_string))
517+ let ids = event_messages(&final_reset)
518+ .iter()
519+ .filter_map(|event| event.event_id.as_deref())
492520 .collect::<Vec<_>>();
493- assert_eq!(
494- reset_ids,
495- vec![
496- anchor_id.to_string(),
497- middle_id.to_string(),
498- recent_id.to_string(),
499- latest_id.to_string(),
500- ]
501- );
502-
521+ assert!(ids.contains(&fresh_id));
522+ assert!(ids.contains(&middle_id));
523+ assert!(ids.contains(&old_id));
503524 shut_down(&room.handle, &mut room.events, room.running).await;
504525 }
505526
506527 #[tokio::test]
507-async fn a_recent_window_containing_the_cached_anchor_overlaps_under_every_policy() {
508- let cases = [
509- (
510- room_id!("!policy-recent:localhost"),
511- event_id!("$policy-recent-anchor:localhost"),
512- HistoryRepairPolicy::StartRecent,
513- ),
514- (
515- room_id!("!policy-bridge:localhost"),
516- event_id!("$policy-bridge-anchor:localhost"),
517- HistoryRepairPolicy::Bridge,
518- ),
519- (
520- room_id!("!policy-short-gap:localhost"),
521- event_id!("$policy-short-anchor:localhost"),
522- HistoryRepairPolicy::BridgeShortGaps { threshold_ms: 0 },
523- ),
524- ];
525-
526- for (room_id, anchor_id, policy) in cases {
527- let mut room = open_room(room_id).await;
528- sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
529- room.server
530- .mock_room_messages()
531- .ok(
532- RoomMessagesResponseTemplate::default().events(vec![history_event(
533- room_id,
534- anchor_id,
535- "same anchor in recent history",
536- )]),
537- )
538- .mock_once()
539- .mount()
540- .await;
541-
542- let report = room
543- .handle
544- .history_repair(
545- room_id.to_owned(),
546- Some(policy.clone()),
547- HistoryRepairBudget::default(),
548- )
549- .await
550- .unwrap();
551- assert_eq!(report.requested_policy, policy);
552- assert_eq!(report.strategy, HistoryRepairStrategy::Overlap);
553- assert_eq!(report.continuity, HistoryContinuity::Verified);
554- assert_eq!(report.pages, 1);
555- assert!(report.recent_available);
556-
557- let requests = room.server.received_requests().await.unwrap();
558- assert_eq!(
559- requests
560- .iter()
561- .filter(|request| request.url.path().ends_with("/messages"))
562- .count(),
563- 1
564- );
565- shut_down(&room.handle, &mut room.events, room.running).await;
566- }
567-}
568-
569-#[tokio::test]
570-async fn short_gap_policy_stays_recent_when_coverage_is_unknown() {
571- let room_id = room_id!("!unknown-coverage:localhost");
572- let anchor_id = event_id!("$unknown-anchor:localhost");
573- let fresh_id = event_id!("$unknown-fresh:localhost");
574- let mut room = open_room(room_id).await;
575- sync_cached_event(&mut room, room_id, anchor_id, "cached anchor").await;
528+async fn failed_automatic_recent_load_is_public_and_keeps_the_cached_timeline() {
529+ let room_id = room_id!("!failed-recent:localhost");
530+ let old_id = event_id!("$failed-old:localhost");
531+ let mut room = start_room(room_id).await;
576532 room.server
577533 .mock_room_messages()
578- .ok(RoomMessagesResponseTemplate::default()
579- .end_token("recent-prev")
580- .events(vec![history_event(room_id, fresh_id, "recent window")]))
534+ .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
535+ "errcode": "M_FORBIDDEN",
536+ "error": "history is unavailable",
537+ })))
581538 .mock_once()
582539 .mount()
583540 .await;
584-
585- let report = room
586- .handle
587- .history_repair(
588- room_id.to_owned(),
589- Some(HistoryRepairPolicy::BridgeShortGaps {
590- threshold_ms: 60_000,
591- }),
592- HistoryRepairBudget::default(),
593- )
594- .await
595- .unwrap();
596- assert_eq!(report.coverage_duration_ms, None);
597- assert!(!report.threshold_evaluated);
598- assert_eq!(report.strategy, HistoryRepairStrategy::Recent);
599- assert_eq!(report.continuity, HistoryContinuity::Unresolved);
600- assert_eq!(report.pages, 1);
601-
602- let requests = room.server.received_requests().await.unwrap();
603- assert_eq!(
604- requests
605- .iter()
606- .filter(|request| request.url.path().ends_with("/messages"))
607- .count(),
608- 1
609- );
541+ cache_event_before_open(&room, room_id, old_id, "kept event").await;
542+ open_room(&mut room, room_id).await;
543+ let state = wait_for_recent(&mut room, room_id).await;
544+ 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());
610548 shut_down(&room.handle, &mut room.events, room.running).await;
611549 }