diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index 71f6092a..05ae1e2d 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -1222,6 +1222,8 @@ pub(crate) fn start_with(ctx: Context, session: &str, inbox: Receiver, // made, registered, key packages up. Never stops the account. e2ee::begin(&saved, ctx.commands.clone()); messages::refresh(&ctx, &saved); + // Catch missed membership hints even when this server has no live socket. + e2ee::resume(&saved); serve_inbox(&ctx, &saved, &live, inbox); // The socket thread sleeps on the socket; this is how it hears the adapter stopped. live.wake(); diff --git a/crates/chatd/src/adapters/tiny/e2ee.rs b/crates/chatd/src/adapters/tiny/e2ee.rs index fb81eeca..efdfe269 100644 --- a/crates/chatd/src/adapters/tiny/e2ee.rs +++ b/crates/chatd/src/adapters/tiny/e2ee.rs @@ -989,6 +989,8 @@ pub(crate) struct Device { /// Conversations where someone the server is adding has no device in the group yet, as the /// last sync saw them. joining: BTreeSet, + /// Coalesce reconnect and device-link membership catch-up workers. + membership_nudge: bool, /// Conversations with a row that did not open because this device was not in the group, or /// not at its epoch, yet. Read again when the group's handshake next comes. unopened: BTreeSet, @@ -1141,6 +1143,14 @@ pub(crate) fn begin(saved: &Saved, commands: std::sync::mpsc::Sender (200, json!([])), + "GET" if path == "/v1/directs" => (200, json!([{ + "id": CONVERSATION, "e2ee": true, + "peer": {"user_id": if caller == ADA { BEA } else { ADA }, "display_name": "Peer"} + }])), "GET" if path == "/v1/capabilities" => ( 200, json!({ "features": ["e2ee_text"], "e2ee_text": "optional" }), @@ -422,6 +427,67 @@ fn the_first_message_of_a_new_conversation_opens_for_the_other_person() { prove_first_encrypted_message().expect("first message opens for the other person"); } +#[test] +fn resuming_after_publishing_conversations_settles_an_add_missed_while_offline() { + let fake = Fake::start(0); + let mut creator = device(&fake, ADA, "offline-creator"); + creator.sync(CONVERSATION).unwrap(); + assert_eq!(lock(&fake.server).epoch, Some(0)); + // The creator is now offline. The new recipient can trigger a signed Add, + // but cannot commit it or join until the creator processes it. + let mut recipient = device(&fake, BEA, "waiting-recipient"); + recipient.sync(CONVERSATION).unwrap(); + assert!(!recipient.engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap())); + assert!(lock(&fake.server).pending.iter().any(|add| add.issued)); + + let creator_saved = creator.saved.clone(); + let recipient_saved = recipient.saved.clone(); + let creator = Arc::new(Mutex::new(creator)); + let recipient = Arc::new(Mutex::new(recipient)); + let registry_key = |saved: &Saved| (saved.base_url.clone(), saved.user_id.clone()); + struct Registered(Vec<(String, String)>); + impl Drop for Registered { + fn drop(&mut self) { + let mut registry = lock(devices()); + for key in &self.0 { + registry.remove(key); + } + } + } + let _registered = Registered(vec![registry_key(&creator_saved), registry_key(&recipient_saved)]); + lock(devices()).insert(registry_key(&creator_saved), creator.clone()); + lock(devices()).insert(registry_key(&recipient_saved), recipient.clone()); + let context = |saved: &Saved| { + let hub = Arc::new(crate::hub::Hub::new(crate::store::Store::open(Path::new(":memory:")).unwrap())); + crate::adapters::local(protocol::AccountKey { network: protocol::Network::Tiny, account: saved.user_id.clone() }, hub) + }; + let (_adapter, creator_ctx, _inbox) = context(&creator_saved); + // Navigation refreshes the list too, but must not start membership workers. + super::super::messages::publish(&creator_ctx, &creator_saved, &HashMap::new()); + assert!(!lock(&creator).membership_nudge); + assert_eq!(lock(&fake.server).epoch, Some(0)); + // Startup and socket ready/resume explicitly nudge after list publication. + resume(&creator_saved); + let deadline = Instant::now() + Duration::from_secs(5); + while lock(&fake.server).epoch == Some(0) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let committed = lock(&fake.server).epoch == Some(1); + if committed { + let (_adapter, ctx, _inbox) = context(&recipient_saved); + super::super::messages::publish(&ctx, &recipient_saved, &HashMap::new()); + resume(&recipient_saved); + while !lock(&recipient).engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap()) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + } + let joined = lock(&recipient).engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap()); + assert!(committed, "returning creator must commit the missed Add without opening or sending a message"); + assert!(joined, "returning recipient must take its Welcome without a live hint"); + let answer = lock(&recipient).post(CONVERSATION, "after-resume", Kind::Message, None, &Envelope::message("joined after reconnect"), &[]).unwrap(); + assert_eq!(text(&fake.read(&mut lock(&creator)), &id_of(&answer)), "joined after reconnect"); +} + /// A row that did not open because this device was not in the group yet (here, the sync of the /// read failed) is read again when the group arrives, not only when another message does. #[test] diff --git a/crates/chatd/src/adapters/tiny/e2ee/linking.rs b/crates/chatd/src/adapters/tiny/e2ee/linking.rs index 9f021a4e..f4f2c029 100644 --- a/crates/chatd/src/adapters/tiny/e2ee/linking.rs +++ b/crates/chatd/src/adapters/tiny/e2ee/linking.rs @@ -286,9 +286,24 @@ const NUDGES: [Duration; 3] = [ /// Settle a device addition or removal immediately, so a linked device gets its Welcome before /// its first send. Older servers batch membership changes, so retain the later retries too. pub(super) fn nudge(device: Shared) { + { + let mut state = lock(&device); + if !state.ready() || state.membership_nudge { + return; + } + state.membership_nudge = true; + } + struct Running(Shared); + impl Drop for Running { + fn drop(&mut self) { + lock(&self.0).membership_nudge = false; + } + } + let running = Running(device.clone()); let _ = std::thread::Builder::new() .name("tiny-e2ee-nudge".into()) .spawn(move || { + let _running = running; for wait in NUDGES { std::thread::sleep(wait); let conversations = lock(&device).known_encrypted(); @@ -298,8 +313,10 @@ pub(super) fn nudge(device: Shared) { if !device.ready() { return; } - if let Err(why) = device.sync(&conversation) { - tracing::debug!("chatd: Tiny e2ee: {conversation}: {why}"); + match device.sync(&conversation) { + Ok(()) => device.reload(&BTreeSet::from([conversation])), + // Cached groups can belong to rooms we have left or that were deleted. + Err(why) => tracing::debug!("chatd: Tiny e2ee: {conversation}: {why}"), } } } diff --git a/crates/chatd/src/adapters/tiny/live.rs b/crates/chatd/src/adapters/tiny/live.rs index 0a2f60a2..52a5316f 100644 --- a/crates/chatd/src/adapters/tiny/live.rs +++ b/crates/chatd/src/adapters/tiny/live.rs @@ -1810,6 +1810,9 @@ fn ready_heard(ctx: &Context, saved: &Saved, live: &Live, seat: &mut Seat, heard if fresh { super::messages::publish(ctx, saved, &seat.names); } + // Both fresh ready and resume can follow a missed membership hint. UI list + // refreshes also call publish, so catch-up belongs here rather than there. + super::e2ee::resume(saved); } /// Who is in one room's call, with their names, as a voice channel's occupants. A call chatd diff --git a/docs/e2ee-text-client.md b/docs/e2ee-text-client.md index 606f2daf..a60a5a5e 100644 --- a/docs/e2ee-text-client.md +++ b/docs/e2ee-text-client.md @@ -37,7 +37,12 @@ that account here (a new device) reads them. "Erase" deletes the directory and t starts the group if nobody has (`POST .../group`), applies the handshake feed in order (an Add for a device not yet verified makes it fetch that person's list and try once more), and commits what is pending, with the Welcome for every device it adds. It runs on `e2ee.handshake`, before - every send, and when a message is from an epoch ahead (once per read). + every send, and when a message is from an epoch ahead (once per read). After publishing the + conversation list at startup, and after socket resume, a background worker also revisits known + encrypted groups. This catches pending additions whose live hint was missed while offline, + including conversations with no messages. It retries after 35 and then 40 seconds for older + servers that batch membership changes, and requests a message backfill after successful sync. + Concurrent catch-up requests share one worker per device. - **Sending.** Never plaintext to an encrypted conversation: if this device is not ready, the send is an error. While the server lists an Add for someone with no device in the group yet, the send waits, syncing every 2 s, up to 45 s: the server signs Adds only after its commit batch diff --git a/e2e/scenarios/encrypted-membership-reconnect/README.md b/e2e/scenarios/encrypted-membership-reconnect/README.md new file mode 100644 index 00000000..951ebc60 --- /dev/null +++ b/e2e/scenarios/encrypted-membership-reconnect/README.md @@ -0,0 +1,51 @@ +# Encrypted membership after reconnect + +Required recorded real-app scenario, using isolated profiles and invented accounts: + +1. Sign in Ada and create an encrypted direct conversation with Bea before Bea + has registered an encryption device. Ada's device is the sole group member. +2. Close Ada's app. Sign in Bea and open the direct conversation. Verify that + Bea cannot send yet. The server should have a signed, uncommitted Add for Bea. +3. Start Ada's release-profile app again. Leave it at the conversation list; + do not open the conversation, send a message, or press Update encryption. +4. Restart Bea's app, then send a unique message from Bea. It must send and + Ada must be able to read it. Reply from Ada and verify Bea reads it too. +5. Repeat with Ada's process left running but its network disconnected while + Bea's Add is created. Restore the network and repeat step 4. This checks + socket resume as well as cold startup. Allow 90 seconds for bounded retries + on older servers that batch membership changes. + +Run the same scenario on the base and branch on every platform. The base must +fail at step 4 because connection/list refresh does not process the pending Add. +The lab must record and publish these runs; no full GUI proof is claimed here. +All platform drivers and recordings are explicitly deferred: + +| Platform | Driver | Recording | +| --- | --- | --- | +| Linux | Deferred: no `drive.py` or `drive.linux.sh` | Not run | +| Windows | Deferred: no `drive.py` or `drive.windows.ps1` | Not run | +| Android | Deferred: no `drive.py` or `drive.android.sh` | Not run | +| macOS | Deferred: no `drive.py` or `drive.macos.sh`; lab permissions/host blocked | Not run | +| iOS | Deferred: no `drive.py` or `drive.ios.sh`; lab has no passing run | Not run | + +Linux, Windows and Android can record today, but this scenario still needs real-app +UI automation for two isolated accounts and both cold-start and network-resume +cases. The existing daemon proof drivers do not implement that interaction. +`scenario.json` lists required coverage, not implemented support. There is nothing +for the lab to execute for this scenario yet. Keep the PR draft; it cannot claim +`e2e-proof` or satisfy the quality gate until those drivers and base/branch +recordings exist. This scenario is not in the release suite. + +The focused source regression uses real MLS devices and conversation +list publication followed by the explicit reconnect nudge with an in-process server. It checks that the creator +commits the missed Add, that the recipient takes its Welcome without a live +hint, and that the recipient's first message opens on the creator: + +```sh +cargo test --locked -p chatd --lib resuming_after_publishing_conversations_settles_an_add_missed_while_offline -- --nocapture +``` + +For a behavioral base comparison, transplant the test and its fake server list +routes, leaving production code unchanged. A missing test is not a base failure. +This source regression supplements the required real-app scenario; it is not +`e2e-proof` or a substitute for recordings. diff --git a/e2e/scenarios/encrypted-membership-reconnect/scenario.json b/e2e/scenarios/encrypted-membership-reconnect/scenario.json new file mode 100644 index 00000000..c8de2cae --- /dev/null +++ b/e2e/scenarios/encrypted-membership-reconnect/scenario.json @@ -0,0 +1,7 @@ +{ + "id": "encrypted-membership-reconnect", + "title": "Returning clients finish a pending encrypted-chat admission after missing its live event", + "platforms": ["windows", "macos", "linux", "android", "ios"], + "needs": ["server", "two-people"], + "release": false +}