Commit 7aa42f589ee2
tarball7 files changed+348-29
@@ -77,10 +77,11 @@
7777
rustc = toolchain;7878
};7979
# continuwuity refuses to start without /etc/resolv.conf, which the build sandbox lacks, or with80
- # stdenv's deliberately bogus SSL_CERT_FILE80
+ # stdenv's deliberately bogus SSL_CERT_FILE. no exec, run.nu launches through a shell that exits at81
+ # once and would trip --die-with-parent, so this shell stays as the parent run.nu kills8182
sandboxedHomeserver = pkgs.writeShellScriptBin "conduwuit" ''8283
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt83
- 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 preBuild112
+ cargo check --offline --frozen --lib --target wasm32-unknown-unknown -j 2108113
cargo check --offline --frozen --lib \109114
--target wasm32-unknown-unknown --features ${storeFeature},mirror -j 2110115
runHook postBuild@@ -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",@@ -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 the38
+# OOM killer took it39
+[profile.dev]40
+debug = false2741
2842
[profile.release]2943
opt-level = "z"@@ -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 not1012
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" } | ignore55
+ 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 --all57
+ 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 | ignore5459
}5560
5661
def stop [pid: string] {@@ -70,10 +75,12 @@ def main [] {
7075
MACLTOK_HOMESERVER: $server.url7176
MACLTOK_USERNAME: $username7277
MACLTOK_PASSWORD: $password78
+ MACLTOK_ROOM_NAME: $room_name79
+ MACLTOK_SEEDED: $seeded7380
GECKODRIVER: (which geckodriver | get 0.path)7481
WASM_BINDGEN_TEST_TIMEOUT: "180"7582
} {76
- ^cargo test --target wasm32-unknown-unknown --test worker83
+ ^cargo test --target wasm32-unknown-unknown --test worker --test shared_worker7784
}7885
null7986
} catch {|error| $error }@@ -1,47 +1,209 @@
11
//! a browser-side slice of macltok, login through to a live sync, exported for2
-//! wasm-bindgen so both the worker test and the size measurement reach the same code2
+//! wasm-bindgen so both the worker tests and the size measurement reach the same code33
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 the22
+/// seeded message out of its timeline, and seeing a message of its own come back from23
+/// the server1024
#[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) = rooms54
+ .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::RoomsChanged62
+ ) {}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).await83
+ });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
running4492
.await4593
.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 it98
+fn command<F>(99
+ handle: &ClientHandle,100
+ run: impl FnOnce(ClientHandle) -> F,101
+) -> oneshot::Receiver<Result<(), Error>>102
+where103
+ 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
+ answered111
+}112
+113
+/// a panicking engine task goes silent rather than stopping, and a phase would otherwise wait114
+/// out its whole timeout115
+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.what159
+ ))),160
+ Some(Some(Event::SessionSaveFailed { message })) => Err(JsError::new(&format!(161
+ "saving the session failed while waiting for {}, {message}",162
+ self.what163
+ ))),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.what169
+ ))),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 == body207
+ && (!remote_echo || (event.event_id.is_some() && event.send_state == SendState::Sent))208
+ })209
+}@@ -0,0 +1,21 @@
1
+//! needs a live homeserver, run.nu starts and seeds one and bakes its details in at build time2
+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
+ .await20
+ .unwrap_or_else(|error| panic!("{:?}", wasm_bindgen::JsValue::from(error)));21
+}@@ -1,6 +1,14 @@
1
-//! needs a live homeserver, run.nu starts one and bakes its credentials in at build time1
+//! needs a live homeserver, run.nu starts and seeds one and bakes its details in at build time22
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
.await1828
.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
+ .await36
+ .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
+ .await46
+ .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 need54
+/// the test bundle's own glue55
+#[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() - terminated88
+ );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
+}