atagen

macltok

public
Star0
Watch0

Commit 7aa42f589ee2

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

7 files changed+348-29
harness: prove a real sync and the store lock from browser workers
Modified file: flake.nix+8 additions−3 deletions
@@ -77,10 +77,11 @@
7777 rustc = toolchain;
7878 };
7979 # continuwuity refuses to start without /etc/resolv.conf, which the build sandbox lacks, or with
80- # stdenv's deliberately bogus SSL_CERT_FILE
80+ # stdenv's deliberately bogus SSL_CERT_FILE. no exec, run.nu launches through a shell that exits at
81+ # once and would trip --die-with-parent, so this shell stays as the parent run.nu kills
8182 sandboxedHomeserver = pkgs.writeShellScriptBin "conduwuit" ''
8283 export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
83- exec ${pkgs.lib.getExe pkgs.bubblewrap} --dev-bind / / --tmpfs /etc \
84+ ${pkgs.lib.getExe pkgs.bubblewrap} --die-with-parent --dev-bind / / --tmpfs /etc \
8485 --ro-bind ${pkgs.writeText "resolv.conf" "nameserver 127.0.0.1\n"} /etc/resolv.conf \
8586 ${pkgs.lib.getExe' pkgs.matrix-continuwuity "conduwuit"} "$@"
8687 '';
@@ -93,7 +94,10 @@
9394 src = self;
9495 cargoLock.lockFile = ./Cargo.lock;
9596 preBuild = "export NIX_BUILD_CORES=2";
96- buildFeatures = [ "sqlite" ];
97+ buildFeatures = [
98+ "sqlite"
99+ "mirror"
100+ ];
97101 nativeBuildInputs = [ pkgs.pkg-config ];
98102 buildInputs = [ pkgs.sqlite ];
99103 SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt";
@@ -105,6 +109,7 @@
105109 cargoLock.lockFile = ./Cargo.lock;
106110 buildPhase = ''
107111 runHook preBuild
112+ cargo check --offline --frozen --lib --target wasm32-unknown-unknown -j 2
108113 cargo check --offline --frozen --lib \
109114 --target wasm32-unknown-unknown --features ${storeFeature},mirror -j 2
110115 runHook postBuild
Modified file: harness/web/Cargo.lock+6 additions
@@ -1722,6 +1722,7 @@ dependencies = [
17221722 "getrandom 0.4.3",
17231723 "http",
17241724 "imbl",
1725+ "js-sys",
17251726 "matrix-sdk",
17261727 "matrix-sdk-crypto",
17271728 "matrix-sdk-store-encryption",
@@ -1737,6 +1738,9 @@ dependencies = [
17371738 "tracing",
17381739 "url",
17391740 "uuid",
1741+ "wasm-bindgen",
1742+ "wasm-bindgen-futures",
1743+ "web-sys",
17401744 ]
17411745
17421746 [[package]]
@@ -1744,6 +1748,8 @@ name = "macltok-web"
17441748 version = "0.1.0"
17451749 dependencies = [
17461750 "futures-channel",
1751+ "futures-util",
1752+ "gloo-timers",
17471753 "js-sys",
17481754 "macltok",
17491755 "wasm-bindgen",
Modified file: harness/web/Cargo.toml+16 additions−2 deletions
@@ -19,11 +19,25 @@ macltok = { path = "../.." }
1919 wasm-bindgen = "0.2"
2020 wasm-bindgen-futures = "0.4"
2121 futures-channel = "0.3"
22+futures-util = { version = "0.3", default-features = false }
23+gloo-timers = { version = "0.3", features = ["futures"] }
24+js-sys = "0.3"
2225
2326 [dev-dependencies]
24-js-sys = "0.3"
2527 wasm-bindgen-test = "0.3"
26-web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope"] }
28+web-sys = { version = "0.3", features = [
29+ "Blob",
30+ "BlobPropertyBag",
31+ "DedicatedWorkerGlobalScope",
32+ "SharedWorkerGlobalScope",
33+ "Url",
34+ "Worker",
35+] }
36+
37+# with debuginfo the test wasm is 580 MB and wasm-bindgen-test-runner peaked at 11 GB before the
38+# OOM killer took it
39+[profile.dev]
40+debug = false
2741
2842 [profile.release]
2943 opt-level = "z"
Modified file: harness/web/run.nu+9 additions−2 deletions
@@ -5,6 +5,8 @@ const crate = path self .
55 const root = path self ../..
66 const username = "harness"
77 const password = "harness-password"
8+const room_name = "harness"
9+const seeded = "seeded by run.nu"
810
911 # the shared build wrapper sets CARGO_TARGET_DIR, the nix sandbox does not
1012 def target [] { $env.CARGO_TARGET_DIR? | default ($root | path join target) }
@@ -50,7 +52,10 @@ def login [url: string] {
5052
5153 def seed [url: string] {
5254 let auth = [Authorization $'Bearer ((login $url).access_token)']
53- http post --headers $auth --content-type application/json $'($url)/_matrix/client/v3/createRoom' { name: "harness" } | ignore
55+ let room = http post --headers $auth --content-type application/json $'($url)/_matrix/client/v3/createRoom' { name: $room_name }
56+ let room_id = $room.room_id | url encode --all
57+ let message = { msgtype: "m.text", body: $seeded }
58+ http put --headers $auth --content-type application/json $'($url)/_matrix/client/v3/rooms/($room_id)/send/m.room.message/seed' $message | ignore
5459 }
5560
5661 def stop [pid: string] {
@@ -70,10 +75,12 @@ def main [] {
7075 MACLTOK_HOMESERVER: $server.url
7176 MACLTOK_USERNAME: $username
7277 MACLTOK_PASSWORD: $password
78+ MACLTOK_ROOM_NAME: $room_name
79+ MACLTOK_SEEDED: $seeded
7380 GECKODRIVER: (which geckodriver | get 0.path)
7481 WASM_BINDGEN_TEST_TIMEOUT: "180"
7582 } {
76- ^cargo test --target wasm32-unknown-unknown --test worker
83+ ^cargo test --target wasm32-unknown-unknown --test worker --test shared_worker
7784 }
7885 null
7986 } catch {|error| $error }
Modified file: harness/web/src/lib.rs+181 additions−19 deletions
@@ -1,47 +1,209 @@
11 //! a browser-side slice of macltok, login through to a live sync, exported for
2-//! wasm-bindgen so both the worker test and the size measurement reach the same code
2+//! wasm-bindgen so both the worker tests and the size measurement reach the same code
33
4-use futures_channel::oneshot;
4+use std::{pin::pin, sync::Arc};
5+
6+use futures_channel::{mpsc, oneshot};
7+use futures_util::{
8+ StreamExt,
9+ future::{Either, select},
10+};
11+use gloo_timers::future::TimeoutFuture;
512 use macltok::{
6- ConnectionState, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Session, StoreConfig,
13+ ClientHandle, EncryptedSessionStore, EngineConfig, Error, Event, EventStream, MessageContent,
14+ PasswordLogin, RoomMembership, SendState, Session, StoreConfig, TimelineContent, TimelineDiff,
15+ TimelineId, TimelineItem, imbl::Vector,
716 };
817 use wasm_bindgen::prelude::*;
918
19+const PHASE_TIMEOUT_MS: f64 = 30_000.0;
20+
21+/// logs in, then proves a real sliding sync by finding the seeded room, reading the
22+/// seeded message out of its timeline, and seeing a message of its own come back from
23+/// the server
1024 #[wasm_bindgen]
1125 pub async fn smoke(
1226 homeserver: String,
1327 username: String,
1428 password: String,
1529 store_name: String,
30+ room_name: String,
31+ seeded: String,
1632 ) -> Result<(), JsError> {
17- let sessions = EncryptedSessionStore::indexeddb(
18- StoreConfig::indexeddb(store_name, "macltok-web").with_lock_holder("macltok-web-worker"),
19- );
33+ let sessions = Arc::new(EncryptedSessionStore::indexeddb(
34+ StoreConfig::indexeddb(store_name.clone(), "macltok-web"),
35+ ));
2036 let login = PasswordLogin::new(homeserver, username, password);
21- let session = Session::login_password(login, &sessions).await?;
22- let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;
37+ let session = Session::login_password(login, sessions).await?;
38+ let (engine, handle, events) = session.build(EngineConfig::default()).await?;
39+ let (panicked, panics) = mpsc::unbounded();
40+ macltok::on_panic(move |message| {
41+ let _ = panicked.unbounded_send(message.to_owned());
42+ });
43+ let mut events = Watched { events, panics };
2344 let (done, running) = oneshot::channel();
2445 wasm_bindgen_futures::spawn_local(async move {
2546 let _ = done.send(engine.run().await);
2647 });
2748
28- let mut online = false;
29- let mut rooms = false;
30- while !(online && rooms) {
31- match events.recv().await {
32- Some(Event::ConnectionChanged(ConnectionState::Online)) => online = true,
33- Some(Event::RoomsChanged) => rooms = true,
34- Some(Event::Stopped) | None => {
35- return Err(JsError::new("engine stopped before it came online"));
36- }
37- Some(_) => {}
49+ let mut phase = Phase::new("the seeded room to sync");
50+ let (_never, mut idle) = oneshot::channel();
51+ let room_id = loop {
52+ let rooms = handle.rooms().await;
53+ if let Some(room) = rooms
54+ .into_iter()
55+ .find(|room| room.membership == RoomMembership::Joined && room.name == room_name)
56+ {
57+ break room.room_id;
3858 }
59+ while !matches!(
60+ phase.next(&mut events, &mut idle).await?,
61+ Event::RoomsChanged
62+ ) {}
63+ };
64+ let timeline_id = TimelineId::from(room_id.clone());
65+
66+ let mut phase = Phase::new("the seeded message in the opened timeline");
67+ let mut opening = command(
68+ &handle,
69+ |handle| async move { handle.open_room(room_id).await },
70+ );
71+ let mut items = None;
72+ while !contains(&items, &seeded, false) {
73+ let event = phase.next(&mut events, &mut opening).await?;
74+ track(&timeline_id, event, &mut items)?;
75+ }
76+
77+ let sent = format!("{seeded}, answered from {store_name}");
78+ let mut phase = Phase::new("the sent message to come back from the server");
79+ let content = MessageContent::text(sent.clone());
80+ let target = timeline_id.clone();
81+ let mut sending = command(&handle, |handle| async move {
82+ handle.send_message(target, content).await
83+ });
84+ while !contains(&items, &sent, true) {
85+ let event = phase.next(&mut events, &mut sending).await?;
86+ track(&timeline_id, event, &mut items)?;
3987 }
4088
4189 handle.shutdown().await?;
42- while events.recv().await.is_some() {}
90+ while events.events.recv().await.is_some() {}
4391 running
4492 .await
4593 .map_err(|_| JsError::new("engine task vanished"))??;
4694 Ok(())
4795 }
96+
97+/// answers only arrive while the event stream is drained, so commands run beside it
98+fn command<F>(
99+ handle: &ClientHandle,
100+ run: impl FnOnce(ClientHandle) -> F,
101+) -> oneshot::Receiver<Result<(), Error>>
102+where
103+ F: Future<Output = Result<(), Error>> + 'static,
104+{
105+ let (answer, answered) = oneshot::channel();
106+ let pending = run(handle.clone());
107+ wasm_bindgen_futures::spawn_local(async move {
108+ let _ = answer.send(pending.await);
109+ });
110+ answered
111+}
112+
113+/// a panicking engine task goes silent rather than stopping, and a phase would otherwise wait
114+/// out its whole timeout
115+struct Watched {
116+ events: EventStream,
117+ panics: mpsc::UnboundedReceiver<String>,
118+}
119+
120+struct Phase {
121+ what: &'static str,
122+ deadline: f64,
123+}
124+
125+impl Phase {
126+ fn new(what: &'static str) -> Self {
127+ Self {
128+ what,
129+ deadline: js_sys::Date::now() + PHASE_TIMEOUT_MS,
130+ }
131+ }
132+
133+ async fn next(
134+ &mut self,
135+ watched: &mut Watched,
136+ pending: &mut oneshot::Receiver<Result<(), Error>>,
137+ ) -> Result<Event, JsError> {
138+ let left = (self.deadline - js_sys::Date::now()).max(0.0);
139+ let timeout = TimeoutFuture::new(left as u32);
140+ let panicked = watched.panics.next();
141+ let outcome = match select(pin!(watched.events.recv()), select(timeout, panicked)).await {
142+ Either::Left((event, _)) => Some(event),
143+ Either::Right((Either::Left(_), _)) => None,
144+ Either::Right((Either::Right((message, _)), _)) => {
145+ return Err(JsError::new(&format!(
146+ "panicked while waiting for {}, {}",
147+ self.what,
148+ message.unwrap_or_default()
149+ )));
150+ }
151+ };
152+ if let Ok(Some(Err(error))) = pending.try_recv() {
153+ return Err(JsError::new(&format!("{} failed, {error}", self.what)));
154+ }
155+ match outcome {
156+ Some(Some(Event::Stopped) | None) => Err(JsError::new(&format!(
157+ "engine stopped while waiting for {}",
158+ self.what
159+ ))),
160+ Some(Some(Event::SessionSaveFailed { message })) => Err(JsError::new(&format!(
161+ "saving the session failed while waiting for {}, {message}",
162+ self.what
163+ ))),
164+ Some(Some(event)) => Ok(event),
165+ None => Err(JsError::new(&format!(
166+ "timed out after {}s waiting for {}",
167+ PHASE_TIMEOUT_MS / 1000.0,
168+ self.what
169+ ))),
170+ }
171+ }
172+}
173+
174+fn track(
175+ timeline_id: &TimelineId,
176+ event: Event,
177+ items: &mut Option<Vector<TimelineItem>>,
178+) -> Result<(), JsError> {
179+ match event {
180+ Event::TimelineReset {
181+ timeline_id: id,
182+ items: snapshot,
183+ } if &id == timeline_id => *items = Some(snapshot),
184+ Event::TimelineChanged {
185+ timeline_id: id,
186+ diffs,
187+ } if &id == timeline_id => {
188+ if let Some(items) = items {
189+ TimelineDiff::try_apply_all(diffs, items)
190+ .map_err(|error| JsError::new(&format!("timeline diff misfit, {error:?}")))?;
191+ }
192+ }
193+ _ => {}
194+ }
195+ Ok(())
196+}
197+
198+fn contains(items: &Option<Vector<TimelineItem>>, body: &str, remote_echo: bool) -> bool {
199+ items.iter().flatten().any(|item| {
200+ let TimelineItem::Event(event) = item else {
201+ return false;
202+ };
203+ let TimelineContent::Message(message) = &event.content else {
204+ return false;
205+ };
206+ message.body == body
207+ && (!remote_echo || (event.event_id.is_some() && event.send_state == SendState::Sent))
208+ })
209+}
Added file: harness/web/tests/shared_worker.rs+21 additions
@@ -0,0 +1,21 @@
1+//! needs a live homeserver, run.nu starts and seeds one and bakes its details in at build time
2+
3+use wasm_bindgen::JsCast;
4+use wasm_bindgen_test::*;
5+
6+wasm_bindgen_test_configure!(run_in_shared_worker);
7+
8+#[wasm_bindgen_test]
9+async fn logs_in_and_syncs() {
10+ assert!(js_sys::global().is_instance_of::<web_sys::SharedWorkerGlobalScope>());
11+ macltok_web::smoke(
12+ env!("MACLTOK_HOMESERVER").into(),
13+ env!("MACLTOK_USERNAME").into(),
14+ env!("MACLTOK_PASSWORD").into(),
15+ "macltok-shared".into(),
16+ env!("MACLTOK_ROOM_NAME").into(),
17+ env!("MACLTOK_SEEDED").into(),
18+ )
19+ .await
20+ .unwrap_or_else(|error| panic!("{:?}", wasm_bindgen::JsValue::from(error)));
21+}
Modified file: harness/web/tests/worker.rs+107 additions−3 deletions
@@ -1,6 +1,14 @@
1-//! needs a live homeserver, run.nu starts one and bakes its credentials in at build time
1+//! needs a live homeserver, run.nu starts and seeds one and bakes its details in at build time
22
3-use wasm_bindgen::JsCast;
3+use std::sync::Arc;
4+
5+use js_sys::{Array, Promise, Reflect};
6+use macltok::{
7+ AccountError, EncryptedSessionStore, EngineConfig, PasswordLogin, Restore, Session,
8+ SessionStore, StoreConfig,
9+};
10+use wasm_bindgen::{JsCast, JsValue};
11+use wasm_bindgen_futures::JsFuture;
412 use wasm_bindgen_test::*;
513
614 wasm_bindgen_test_configure!(run_in_dedicated_worker);
@@ -12,8 +20,104 @@ async fn logs_in_and_syncs() {
1220 env!("MACLTOK_HOMESERVER").into(),
1321 env!("MACLTOK_USERNAME").into(),
1422 env!("MACLTOK_PASSWORD").into(),
15- "macltok-web".into(),
23+ "macltok-dedicated".into(),
24+ env!("MACLTOK_ROOM_NAME").into(),
25+ env!("MACLTOK_SEEDED").into(),
1626 )
1727 .await
1828 .unwrap_or_else(|error| panic!("{:?}", wasm_bindgen::JsValue::from(error)));
1929 }
30+
31+#[wasm_bindgen_test]
32+async fn one_engine_per_store() {
33+ assert_secure_context();
34+ let first = Session::login_password(login(), sessions("macltok-lock"))
35+ .await
36+ .expect("first login");
37+ assert_in_use(Session::login_password(login(), sessions("macltok-lock")).await);
38+ let (engine, handle, events) = first.build(EngineConfig::default()).await.unwrap();
39+ assert_in_use(Session::restore(sessions("macltok-lock")).await);
40+ drop(engine);
41+ drop(events);
42+ assert_in_use(Session::restore(sessions("macltok-lock")).await);
43+ drop(handle);
44+ let Some(Restore::Resumed(second)) = Session::restore(sessions("macltok-lock"))
45+ .await
46+ .expect("restore once the first engine is gone")
47+ else {
48+ panic!("the saved login did not resume");
49+ };
50+ second.build(EngineConfig::default()).await.unwrap();
51+}
52+
53+/// the holder is plain js in a nested worker, since a second wasm instance would need
54+/// the test bundle's own glue
55+#[wasm_bindgen_test]
56+async fn a_terminated_worker_frees_the_store() {
57+ assert_secure_context();
58+ let holder = r#"navigator.locks.request("macltok:macltok-orphan", () => {
59+ postMessage("held");
60+ return new Promise(() => {});
61+ });"#;
62+ let options = web_sys::BlobPropertyBag::new();
63+ options.set_type("text/javascript");
64+ let blob = web_sys::Blob::new_with_str_sequence_and_options(
65+ &Array::of1(&JsValue::from_str(holder)),
66+ &options,
67+ )
68+ .unwrap();
69+ let url = web_sys::Url::create_object_url_with_blob(&blob).unwrap();
70+ let worker = web_sys::Worker::new(&url).unwrap();
71+ let held = Promise::new(&mut |resolve, _| worker.set_onmessage(Some(&resolve)));
72+ JsFuture::from(held).await.unwrap();
73+
74+ assert_in_use(Session::login_password(login(), sessions("macltok-orphan")).await);
75+ worker.terminate();
76+ let terminated = js_sys::Date::now();
77+ let session = loop {
78+ match Session::login_password(login(), sessions("macltok-orphan")).await {
79+ Err(AccountError::StoreInUse) if js_sys::Date::now() - terminated < 5_000.0 => {
80+ gloo_timers::future::TimeoutFuture::new(50).await;
81+ }
82+ outcome => break outcome.expect("login once the holder is terminated"),
83+ }
84+ };
85+ console_log!(
86+ "the lock came free {}ms after terminate",
87+ js_sys::Date::now() - terminated
88+ );
89+ drop(session);
90+}
91+
92+fn login() -> PasswordLogin {
93+ PasswordLogin::new(
94+ env!("MACLTOK_HOMESERVER"),
95+ env!("MACLTOK_USERNAME"),
96+ env!("MACLTOK_PASSWORD"),
97+ )
98+}
99+
100+fn sessions(store: &str) -> Arc<dyn SessionStore> {
101+ Arc::new(EncryptedSessionStore::indexeddb(StoreConfig::indexeddb(
102+ store,
103+ "macltok-web",
104+ )))
105+}
106+
107+fn assert_secure_context() {
108+ let secure = Reflect::get(&js_sys::global(), &"isSecureContext".into()).unwrap();
109+ assert_eq!(
110+ secure,
111+ JsValue::TRUE,
112+ "the runner's origin is not a secure context, so it has no Web Locks"
113+ );
114+}
115+
116+#[track_caller]
117+fn assert_in_use<T>(outcome: Result<T, AccountError>) {
118+ match outcome {
119+ Err(AccountError::StoreInUse) => {}
120+ Err(error) => panic!("expected StoreInUse, got {error:?}"),
121+ Ok(_) => panic!("expected StoreInUse, got a second session"),
122+ }
123+}