1use std::cell::Cell;
8use std::collections::{BTreeMap, BTreeSet, VecDeque};
9
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12
13use crate::account_diagnostics::{
14 AccountLogCategory, AccountLogCounterKind as Counter, AccountLogDirection, AccountLogLevel,
15 Diagnostic,
16};
17use crate::message::LocalIdentityHandle;
18use crate::runtime_facade::{
19 SyncTransportFrame, parse_sync_message_frame, plan_sync_transport, resolve_sync_action,
20 resolve_sync_actions,
21};
22use crate::{
23 AccountDatabase, ClaimedDelivery, DeliveryClaim, DeliveryDecision, DeliveryState,
24 ProjectionKind, RelayConnectionState, RelayDeliveryResult, RelayDeliveryResultKind,
25 RelayEndpointPlan, RelayFailureKind, RelayResponseFrame, RelayRetryAction, RelaySession,
26 RelaySessionAction, RelaySessionActionKind, SignedNostrEvent, SoftchatError, SyncAction,
27 SyncActionKind, SyncEngine, SyncEngineSnapshot, SyncPhase, parse_relay_endpoint,
28 parse_relay_response_frame, plan_nip42_authentication, plan_relay_retry,
29};
30
31const MAX_TRANSPORT_ID_BYTES: usize = 128;
32pub(crate) const MAX_TRANSPORT_ACTIONS: usize = 128;
33const MAX_TYPING_INDICATORS: usize = 1_024;
34const DELIVERY_LEASE_SECONDS: u32 = 30;
35const SYNC_ID: &str = "account-sync";
36const SYNC_OVERLAP_SECONDS: i64 = 86_400;
37pub(crate) const SYNC_FRAME_SIZE_LIMIT: u64 = 60_000;
38const MAX_SYNC_RESEND_EVENTS: usize = crate::SYNC_MAX_PAGE_LIMIT as usize;
39const TYPING_LIFETIME_SECONDS: i64 = 5;
40const MAX_RETIRED_TRANSPORT_ACTIONS: usize = 1_024;
41
42#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "camelCase")]
45#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
46pub enum AccountTransportActionKind {
47 Connect,
49 SendText,
51 ReceiveText,
53 Close,
55}
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
61pub struct AccountTransportAction {
62 pub run_id: String,
64 pub action_id: String,
66 pub generation: u64,
68 pub kind: AccountTransportActionKind,
70 pub configured_url: String,
72 pub network_url: String,
74 pub noise_remote_static_key: Vec<u8>,
76 pub text_frame: String,
78 pub delay_ms: u64,
80}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
84#[serde(rename_all = "camelCase")]
85#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
86pub enum AccountTransportResultKind {
87 Connected,
89 FrameWritten,
91 FrameReceived,
93 TimedOut,
95 Disconnected,
97 NetworkUnavailable,
99 AuthenticationFailed,
101 RateLimited,
103 ProtocolFailed,
105 Cancelled,
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "camelCase", deny_unknown_fields)]
112#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
113pub struct AccountTransportResult {
114 pub run_id: String,
116 pub action_id: String,
118 pub generation: u64,
120 pub kind: AccountTransportResultKind,
122 pub text_frame: String,
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
130pub struct AccountTypingIndicator {
131 pub conversation_id: String,
133 pub author_public_key: String,
135 pub expires_at: i64,
137}
138
139#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
141#[serde(rename_all = "camelCase")]
142#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
143pub struct AccountTransportBatch {
144 pub actions: Vec<AccountTransportAction>,
146 pub connection_state: RelayConnectionState,
148 pub idle: bool,
150 pub revision: i64,
152 pub typing_indicators: Vec<AccountTypingIndicator>,
154 pub sync: Option<SyncEngineSnapshot>,
156}
157
158#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
159#[serde(rename_all = "camelCase", deny_unknown_fields)]
160pub(crate) struct StoredAccountTransportOutcome {
161 pub actions: Vec<AccountTransportAction>,
162 pub connection_state: RelayConnectionState,
163 pub idle: bool,
164 pub revision: i64,
165 #[serde(default)]
166 pub typing_indicators: Vec<AccountTypingIndicator>,
167 #[serde(default)]
168 pub sync: Option<SyncEngineSnapshot>,
169}
170
171impl StoredAccountTransportOutcome {
172 pub(crate) fn into_batch(self) -> AccountTransportBatch {
173 AccountTransportBatch {
174 actions: self.actions,
175 connection_state: self.connection_state,
176 idle: self.idle,
177 revision: self.revision,
178 typing_indicators: self.typing_indicators,
179 sync: self.sync,
180 }
181 }
182}
183
184impl From<&AccountTransportBatch> for StoredAccountTransportOutcome {
185 fn from(value: &AccountTransportBatch) -> Self {
186 Self {
187 actions: value.actions.clone(),
188 connection_state: value.connection_state,
189 idle: value.idle,
190 revision: value.revision,
191 typing_indicators: value.typing_indicators.clone(),
192 sync: value.sync.clone(),
193 }
194 }
195}
196
197#[derive(Debug)]
198struct PendingAction {
199 frame_class: &'static str,
200 kind: AccountTransportActionKind,
201 delivery_event_ids: Vec<String>,
202}
203
204#[derive(Debug)]
205struct ActiveDelivery {
206 claim: DeliveryClaim,
207 event_to_intent: BTreeMap<String, String>,
208 remaining_event_ids: BTreeSet<String>,
209 socket_written_event_ids: BTreeSet<String>,
210 expires_at: i64,
211}
212
213#[derive(Debug)]
214struct ActiveSync {
215 engine: SyncEngine,
216 neg_subscription_id: String,
217 fetch_subscription_base_id: Option<String>,
218 fetch_subscription_ids: BTreeSet<String>,
219}
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222enum SyncFrameDisposition {
223 NotSync,
224 Handled,
225 Retry,
226}
227
228#[derive(Debug)]
230pub(crate) struct AccountTransportRun {
231 run_id: String,
232 generation: u64,
233 next_action_sequence: u64,
234 relay_url: String,
235 endpoint: RelayEndpointPlan,
236 inbox_filters: Vec<String>,
237 session: RelaySession,
238 pending: BTreeMap<String, PendingAction>,
239 retired_pending: BTreeMap<String, PendingAction>,
240 active_delivery: Option<ActiveDelivery>,
241 raw_outbound: VecDeque<String>,
242 sync_resend_backlog: VecDeque<String>,
243 sync_resend_event_ids: BTreeSet<String>,
244 sync: Option<ActiveSync>,
245 authentication_fence: bool,
246 authentication_event_id: Option<String>,
247 typing_indicators: BTreeMap<(String, String), i64>,
248 retry_attempt: u32,
249 cancelled: bool,
250 idle: bool,
251 diagnostic_state: Cell<RelayConnectionState>,
252}
253
254impl AccountTransportRun {
255 pub(crate) fn start(
256 run_id: String,
257 inbox_filters: Vec<String>,
258 database: &AccountDatabase,
259 ) -> Result<(Self, AccountTransportBatch), SoftchatError> {
260 validate_transport_id(&run_id)?;
261 let relay_url = database.active_relay_url()?;
262 let endpoint = parse_relay_endpoint(&relay_url)?;
263 let revision = database.info()?.revision;
264 let mut run = Self {
265 run_id,
266 generation: 1,
267 next_action_sequence: 0,
268 relay_url,
269 endpoint,
270 inbox_filters,
271 session: RelaySession::new(),
272 pending: BTreeMap::new(),
273 retired_pending: BTreeMap::new(),
274 active_delivery: None,
275 raw_outbound: VecDeque::new(),
276 sync_resend_backlog: VecDeque::new(),
277 sync_resend_event_ids: BTreeSet::new(),
278 sync: None,
279 authentication_fence: false,
280 authentication_event_id: None,
281 typing_indicators: BTreeMap::new(),
282 retry_attempt: 0,
283 cancelled: false,
284 idle: false,
285 diagnostic_state: Cell::new(RelayConnectionState::Connecting),
286 };
287 run.session.connect()?;
288 let action = run.connect_action(0);
289 database.record_diagnostic(crate::account_diagnostics::connection(
290 RelayConnectionState::Connecting,
291 ));
292 Ok((
293 run,
294 AccountTransportBatch {
295 actions: vec![action],
296 connection_state: RelayConnectionState::Connecting,
297 idle: false,
298 revision,
299 typing_indicators: Vec::new(),
300 sync: None,
301 },
302 ))
303 }
304
305 pub(crate) fn validate_result_preflight(
306 &self,
307 result: &AccountTransportResult,
308 ) -> Result<(), SoftchatError> {
309 if result.run_id != self.run_id {
310 return Err(SoftchatError::InvalidRelaySession);
311 }
312 if result.generation < self.generation {
313 return Ok(());
314 }
315 if result.generation != self.generation || self.cancelled {
316 return Err(SoftchatError::InvalidRelaySession);
317 }
318 let pending = self
319 .retired_pending
320 .get(&result.action_id)
321 .or_else(|| self.pending.get(&result.action_id))
322 .ok_or(SoftchatError::UnsupportedSystemAction)?;
323 validate_result_for_action(result, pending.kind)
324 }
325
326 pub(crate) fn handle_result(
327 &mut self,
328 result: &AccountTransportResult,
329 database: &mut AccountDatabase,
330 identity: &LocalIdentityHandle,
331 now: i64,
332 ) -> Result<AccountTransportBatch, SoftchatError> {
333 self.prune_typing(now);
334 if result.run_id != self.run_id {
335 return Err(SoftchatError::InvalidRelaySession);
336 }
337 if result.generation < self.generation {
338 return self.batch(Vec::new(), database, false);
339 }
340 if result.generation != self.generation || self.cancelled {
341 return Err(SoftchatError::InvalidRelaySession);
342 }
343 if let Some(retired) = self.retired_pending.remove(&result.action_id) {
344 validate_result_for_action(result, retired.kind)?;
345 return self.batch(Vec::new(), database, false);
346 }
347 let pending = self
348 .pending
349 .remove(&result.action_id)
350 .ok_or(SoftchatError::UnsupportedSystemAction)?;
351 validate_result_for_action(result, pending.kind)?;
352
353 let mut actions = Vec::new();
354 match result.kind {
355 AccountTransportResultKind::Connected => {
356 self.session.transport_connected()?;
357 for (index, filter) in self.inbox_filters.iter().enumerate() {
358 self.session.subscribe(
359 format!("softchat-live-{index}"),
360 vec![crate::RelayFilter::from_json(filter)?],
361 )?;
362 }
363 self.session.authenticated()?;
364 self.authentication_fence = false;
365 self.authentication_event_id = None;
366 self.retry_attempt = 0;
367 self.start_sync(database, identity, now)?;
368 }
369 AccountTransportResultKind::FrameWritten => {
370 database.record_diagnostic(crate::account_diagnostics::frame(
371 pending.frame_class,
372 AccountLogDirection::Tx,
373 ));
374 let intent_ids =
375 self.unresolved_intent_ids_for_events(&pending.delivery_event_ids)?;
376 self.session.confirm_sent(&pending.delivery_event_ids)?;
377 if !intent_ids.is_empty() {
378 let claim = self
379 .active_delivery
380 .as_ref()
381 .ok_or(SoftchatError::InvalidDeliveryState)?
382 .claim
383 .clone();
384 database.mark_socket_written(&claim, intent_ids.clone())?;
385 let written_intents = intent_ids.into_iter().collect::<BTreeSet<_>>();
386 if let Some(active) = self.active_delivery.as_mut() {
387 for event_id in &pending.delivery_event_ids {
388 if active
389 .event_to_intent
390 .get(event_id)
391 .is_some_and(|intent_id| written_intents.contains(intent_id))
392 {
393 active.socket_written_event_ids.insert(event_id.clone());
394 }
395 }
396 }
397 }
398 }
399 AccountTransportResultKind::FrameReceived => {
400 match self.process_inbound_frame(&result.text_frame, database, identity, now) {
401 Ok(None) => {}
402 Ok(Some(failure)) => {
403 return self.recover_transport(database, failure, now);
404 }
405 Err(error) => {
406 database.record_diagnostic(
407 Diagnostic::new(
408 AccountLogCategory::Relay,
409 "relay.input.rejected",
410 "Relay input rejected",
411 )
412 .failure(error),
413 );
414 match inbound_failure_disposition(error) {
415 Some(InboundFailureDisposition::Ignore) => {}
416 Some(InboundFailureDisposition::Recover(failure)) => {
417 return self.recover_transport(database, failure, now);
418 }
419 None => return Err(error),
420 }
421 }
422 }
423 }
424 AccountTransportResultKind::TimedOut => {}
425 AccountTransportResultKind::Disconnected => {
426 return self.recover_transport(database, RelayFailureKind::Temporary, now);
427 }
428 AccountTransportResultKind::NetworkUnavailable => {
429 return self.recover_transport(database, RelayFailureKind::NetworkUnavailable, now);
430 }
431 AccountTransportResultKind::AuthenticationFailed => {
432 return self.recover_transport(database, RelayFailureKind::Authentication, now);
433 }
434 AccountTransportResultKind::RateLimited => {
435 return self.recover_transport(database, RelayFailureKind::RateLimited, now);
436 }
437 AccountTransportResultKind::ProtocolFailed => {
438 return self.recover_transport(database, RelayFailureKind::ProtocolPermanent, now);
439 }
440 AccountTransportResultKind::Cancelled => {
441 self.release_active(database)?;
442 let _ = self.session.cancel();
443 self.pending.clear();
444 self.retired_pending.clear();
445 self.raw_outbound.clear();
446 self.sync_resend_backlog.clear();
447 self.sync_resend_event_ids.clear();
448 self.typing_indicators.clear();
449 if let Some(active) = self.sync.as_mut() {
450 let _ = active.engine.cancel();
451 }
452 self.sync = None;
453 self.authentication_fence = false;
454 self.authentication_event_id = None;
455 self.cancelled = true;
456 self.idle = true;
457 return self.batch(Vec::new(), database, true);
458 }
459 }
460 actions.append(&mut self.pump(database, now)?);
461 self.batch(actions, database, false)
462 }
463
464 pub(crate) fn cancel(
465 &mut self,
466 database: &mut AccountDatabase,
467 ) -> Result<AccountTransportBatch, SoftchatError> {
468 if !self.cancelled {
469 self.release_active(database)?;
470 let _ = self.session.cancel();
471 self.pending.clear();
472 self.retired_pending.clear();
473 self.raw_outbound.clear();
474 self.sync_resend_backlog.clear();
475 self.sync_resend_event_ids.clear();
476 self.typing_indicators.clear();
477 if let Some(active) = self.sync.as_mut() {
478 let _ = active.engine.cancel();
479 }
480 self.sync = None;
481 self.authentication_fence = false;
482 self.authentication_event_id = None;
483 self.cancelled = true;
484 self.idle = true;
485 }
486 self.batch(Vec::new(), database, true)
487 }
488
489 pub(crate) fn wake(
490 &mut self,
491 database: &mut AccountDatabase,
492 now: i64,
493 ) -> Result<AccountTransportBatch, SoftchatError> {
494 self.prune_typing(now);
495 if database.active_relay_url()? != self.relay_url {
496 return self.reconnect_selected(database);
497 }
498 let actions = self.pump(database, now)?;
499 self.batch(actions, database, false)
500 }
501
502 pub(crate) fn restart_sync(
503 &mut self,
504 database: &mut AccountDatabase,
505 _identity: &LocalIdentityHandle,
506 _now: i64,
507 ) -> Result<AccountTransportBatch, SoftchatError> {
508 self.reconnect_selected(database)
509 }
510
511 fn reconnect_selected(
512 &mut self,
513 database: &mut AccountDatabase,
514 ) -> Result<AccountTransportBatch, SoftchatError> {
515 if let Some(mut active) = self.sync.take() {
516 for subscription_id in std::mem::take(&mut active.fetch_subscription_ids) {
517 let _ = self.session.close_subscription(&subscription_id);
518 }
519 let _ = active.engine.cancel();
520 }
521 self.raw_outbound.clear();
522 self.sync_resend_backlog.clear();
523 self.sync_resend_event_ids.clear();
524 self.typing_indicators.clear();
525 self.release_active(database)?;
526 self.pending.clear();
527 self.retired_pending.clear();
528 self.authentication_fence = false;
529 self.authentication_event_id = None;
530 self.session.transport_lost()?;
531 self.session = RelaySession::new();
536 self.relay_url = database.active_relay_url()?;
537 self.endpoint = parse_relay_endpoint(&self.relay_url)?;
538 self.generation = self
539 .generation
540 .checked_add(1)
541 .ok_or(SoftchatError::InvalidRelaySession)?;
542 self.session.connect()?;
543 let action = self.connect_action(0);
544 self.batch(vec![action], database, false)
545 }
546
547 fn apply_relay_actions(
548 &mut self,
549 actions: Vec<RelaySessionAction>,
550 database: &mut AccountDatabase,
551 identity: &LocalIdentityHandle,
552 now: i64,
553 ) -> Result<(), SoftchatError> {
554 for action in actions {
555 match action.kind {
556 RelaySessionActionKind::Authenticate => {
557 self.enter_authentication_fence()?;
558 let created_at = u64::try_from(now)
559 .map_err(|_| SoftchatError::InvalidRelayAuthentication)?;
560 let event = identity.with_identity(|authority| {
561 plan_nip42_authentication(
562 authority,
563 &action.value,
564 &self.relay_url,
565 created_at,
566 )
567 })?;
568 self.authentication_event_id = Some(event.id().to_hex());
569 self.session.authenticate(event)?;
570 }
571 RelaySessionActionKind::PersistEvent => {
572 let event = action.event.ok_or(SoftchatError::InvalidRelaySession)?;
573 let event_id = event.id.clone();
574 let is_sync_fetch = self.sync.as_ref().is_some_and(|active| {
575 active.fetch_subscription_ids.contains(&action.value)
576 });
577 if is_sync_fetch {
578 let active = self.sync.as_ref().ok_or(SoftchatError::InvalidSyncState)?;
579 let base_id = active
580 .fetch_subscription_base_id
581 .as_deref()
582 .ok_or(SoftchatError::InvalidSyncState)?;
583 active
584 .engine
585 .validate_fetched_events(base_id, std::slice::from_ref(&event_id))?;
586 }
587 let stored = match identity.with_identity(|authority| {
588 database.ingest_from_relay(authority, vec![event], now, is_sync_fetch)
589 }) {
590 Ok(stored) => stored,
591 Err(error) if invalid_incoming_event(error) => {
592 database.record_diagnostic(
593 Diagnostic::new(
594 AccountLogCategory::Relay,
595 "relay.input.rejected",
596 "Relay input rejected",
597 )
598 .failure(error),
599 );
600 self.session.discard_ingestion(&event_id)?;
601 if is_sync_fetch {
602 return Err(SoftchatError::InvalidSyncState);
606 }
607 continue;
608 }
609 Err(error) => return Err(error),
610 };
611 if is_sync_fetch {
612 let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
613 let base_id = active
614 .fetch_subscription_base_id
615 .as_deref()
616 .ok_or(SoftchatError::InvalidSyncState)?;
617 active
618 .engine
619 .record_fetched_event(base_id.to_owned(), event_id.clone())?;
620 active.engine.confirm_committed(vec![event_id.clone()])?;
621 }
622 let own_public_key =
623 identity.with_identity(|authority| Ok(authority.public_key().to_hex()))?;
624 for projection in stored.ephemeral_projections {
625 if projection.kind == ProjectionKind::Typing
626 && !projection.conversation_id.is_empty()
627 && projection.author_public_key != own_public_key
628 {
629 self.record_typing_indicator(
630 projection.conversation_id,
631 projection.author_public_key,
632 now.saturating_add(TYPING_LIFETIME_SECONDS),
633 );
634 }
635 }
636 self.session.confirm_ingested(&event_id)?;
637 }
638 RelaySessionActionKind::DeliveryChanged => {
639 let decision = action
640 .delivery_decision
641 .ok_or(SoftchatError::InvalidDeliveryState)?;
642 self.commit_delivery_decision(database, decision)?;
643 }
644 RelaySessionActionKind::SubscriptionReady => {
645 let sync_actions = {
646 let Some(active) = self.sync.as_mut() else {
647 continue;
648 };
649 if !active.fetch_subscription_ids.remove(&action.value) {
650 continue;
651 }
652 self.session.close_subscription(&action.value)?;
653 if !active.fetch_subscription_ids.is_empty() {
654 continue;
655 }
656 let base_id = active
657 .fetch_subscription_base_id
658 .take()
659 .ok_or(SoftchatError::InvalidSyncState)?;
660 let actions = active.engine.finish_fetch(base_id)?;
661 resolve_sync_actions(
662 database,
663 &mut active.engine,
664 actions,
665 SYNC_FRAME_SIZE_LIMIT,
666 )?
667 };
668 self.apply_sync_actions(sync_actions, database, identity)?;
669 }
670 RelaySessionActionKind::SubscriptionClosed => {}
671 RelaySessionActionKind::OpenTransport
672 | RelaySessionActionKind::SendFrame
673 | RelaySessionActionKind::CloseTransport
674 | RelaySessionActionKind::ScheduleReconnect => {
675 return Err(SoftchatError::UnsupportedSystemAction);
676 }
677 }
678 }
679 Ok(())
680 }
681
682 fn enter_authentication_fence(&mut self) -> Result<(), SoftchatError> {
683 if self
684 .retired_pending
685 .len()
686 .saturating_add(self.pending.len())
687 > MAX_RETIRED_TRANSPORT_ACTIONS
688 {
689 return Err(SoftchatError::RelaySessionQueueFull);
690 }
691 self.retired_pending.append(&mut self.pending);
692 if let Some(mut active) = self.sync.take() {
693 for subscription_id in std::mem::take(&mut active.fetch_subscription_ids) {
694 self.session.close_subscription(&subscription_id)?;
695 }
696 let _ = active.engine.cancel();
697 }
698 self.raw_outbound.clear();
699 self.sync_resend_backlog.clear();
700 self.sync_resend_event_ids.clear();
701 self.authentication_fence = true;
702 self.authentication_event_id = None;
703 self.idle = false;
704 Ok(())
705 }
706
707 fn should_ignore_during_authentication(&self, frame_json: &str) -> Result<bool, SoftchatError> {
708 if !self.authentication_fence {
709 return Ok(false);
710 }
711 let frame = parse_relay_response_frame(frame_json)?;
712 Ok(match frame {
713 RelayResponseFrame::Auth(_) => false,
714 RelayResponseFrame::Ok(acknowledgement) => {
715 self.authentication_event_id.as_deref() != Some(acknowledgement.event_id.as_str())
716 }
717 RelayResponseFrame::Event { .. }
718 | RelayResponseFrame::Eose { .. }
719 | RelayResponseFrame::Closed { .. }
720 | RelayResponseFrame::Notice(_)
721 | RelayResponseFrame::Count { .. } => true,
722 })
723 }
724
725 fn start_sync(
726 &mut self,
727 database: &mut AccountDatabase,
728 identity: &LocalIdentityHandle,
729 now: i64,
730 ) -> Result<(), SoftchatError> {
731 if self.sync.is_some() {
732 return Ok(());
733 }
734 let since_timestamp = database
735 .sync_checkpoint(SYNC_ID)?
736 .map(|checkpoint| checkpoint.saturating_sub(SYNC_OVERLAP_SECONDS));
737 let suffix = format!("{}-{}", self.generation, self.next_action_sequence);
738 let neg_subscription_id = format!("softchat-neg-{suffix}");
739 let mut engine = SyncEngine::new(
740 SYNC_ID.to_owned(),
741 neg_subscription_id.clone(),
742 format!("softchat-events-{suffix}"),
743 since_timestamp,
744 now,
745 )?;
746 let action = engine.begin()?;
747 let action = resolve_sync_action(database, &mut engine, action, SYNC_FRAME_SIZE_LIMIT)?;
748 self.sync = Some(ActiveSync {
749 engine,
750 neg_subscription_id,
751 fetch_subscription_base_id: None,
752 fetch_subscription_ids: BTreeSet::new(),
753 });
754 self.apply_sync_actions(vec![action], database, identity)?;
755 database.record_diagnostic(Diagnostic::new(
756 AccountLogCategory::Synchronization,
757 "sync.started",
758 "Synchronization started",
759 ));
760 Ok(())
761 }
762
763 fn handle_sync_frame(
764 &mut self,
765 frame_json: &str,
766 database: &mut AccountDatabase,
767 identity: &LocalIdentityHandle,
768 _now: i64,
769 ) -> Result<SyncFrameDisposition, SoftchatError> {
770 let parsed = parse_relay_response_frame(frame_json);
771 if let Ok(frame) = &parsed {
772 database.record_diagnostic(crate::account_diagnostics::frame(
773 crate::account_diagnostics::received_frame_class(frame),
774 AccountLogDirection::Rx,
775 ));
776 }
777 if let Ok(RelayResponseFrame::Ok(acknowledgement)) = parsed
778 && self.sync_resend_event_ids.remove(&acknowledgement.event_id)
779 {
780 if acknowledgement.accepted {
781 return Ok(SyncFrameDisposition::Handled);
782 }
783 return Ok(SyncFrameDisposition::Retry);
784 }
785 let Some(frame) = parse_sync_message_frame(frame_json)? else {
786 return Ok(SyncFrameDisposition::NotSync);
787 };
788 let frame_class = match &frame {
789 SyncTransportFrame::Message { .. } => "NEG_MSG",
790 SyncTransportFrame::Error { .. } => "NEG_ERR",
791 SyncTransportFrame::Control { .. } => "NEG_CONTROL",
792 };
793 database.record_diagnostic(crate::account_diagnostics::frame(
794 frame_class,
795 AccountLogDirection::Rx,
796 ));
797 if self.authentication_fence {
798 return Ok(SyncFrameDisposition::Handled);
799 }
800 let (subscription_id, message) = match frame {
801 SyncTransportFrame::Message {
802 subscription_id,
803 message,
804 } => (subscription_id, message),
805 SyncTransportFrame::Error {
806 subscription_id, ..
807 } => {
808 return Ok(
809 if self
810 .sync
811 .as_ref()
812 .is_some_and(|active| active.neg_subscription_id == subscription_id)
813 {
814 SyncFrameDisposition::Retry
815 } else {
816 SyncFrameDisposition::Handled
817 },
818 );
819 }
820 SyncTransportFrame::Control { subscription_id } => {
821 return Ok(
822 if self
823 .sync
824 .as_ref()
825 .is_some_and(|active| active.neg_subscription_id == subscription_id)
826 {
827 SyncFrameDisposition::Retry
828 } else {
829 SyncFrameDisposition::Handled
830 },
831 );
832 }
833 };
834 if self
835 .sync
836 .as_ref()
837 .is_some_and(|active| active.neg_subscription_id != subscription_id)
838 {
839 return Ok(SyncFrameDisposition::Handled);
840 }
841 let actions = {
842 let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
843 let actions = active.engine.reconcile(subscription_id, message)?;
844 resolve_sync_actions(database, &mut active.engine, actions, SYNC_FRAME_SIZE_LIMIT)?
845 };
846 self.apply_sync_actions(actions, database, identity)?;
847 Ok(SyncFrameDisposition::Handled)
848 }
849
850 fn process_inbound_frame(
851 &mut self,
852 frame_json: &str,
853 database: &mut AccountDatabase,
854 identity: &LocalIdentityHandle,
855 now: i64,
856 ) -> Result<Option<RelayFailureKind>, SoftchatError> {
857 match self.handle_sync_frame(frame_json, database, identity, now)? {
858 SyncFrameDisposition::Handled => return Ok(None),
859 SyncFrameDisposition::Retry => return Ok(Some(RelayFailureKind::Temporary)),
860 SyncFrameDisposition::NotSync => {}
861 }
862 if self.should_ignore_during_authentication(frame_json)? {
863 return Ok(None);
864 }
865 let state_before = self.session.snapshot().state;
866 let relay_actions = self.session.receive(frame_json)?;
867 let subscription_closed = relay_actions
868 .iter()
869 .any(|action| action.kind == RelaySessionActionKind::SubscriptionClosed);
870 let retry_failure = relay_actions
871 .iter()
872 .filter_map(|action| action.delivery_decision.as_ref())
873 .find(|decision| decision.state == DeliveryState::Retryable)
874 .map(|decision| {
875 if decision.relay_message.starts_with("rate-limited:") {
876 RelayFailureKind::RateLimited
877 } else {
878 RelayFailureKind::Temporary
879 }
880 });
881 self.apply_relay_actions(relay_actions, database, identity, now)?;
882 if retry_failure.is_some() {
886 return Ok(retry_failure);
887 }
888 if subscription_closed {
889 return Ok(Some(RelayFailureKind::Temporary));
890 }
891 if state_before == RelayConnectionState::Authenticating
892 && self.session.snapshot().state == RelayConnectionState::Ready
893 {
894 self.authentication_fence = false;
895 self.authentication_event_id = None;
896 self.start_sync(database, identity, now)?;
897 }
898 Ok(None)
899 }
900
901 fn apply_sync_actions(
902 &mut self,
903 actions: Vec<SyncAction>,
904 database: &mut AccountDatabase,
905 identity: &LocalIdentityHandle,
906 ) -> Result<(), SoftchatError> {
907 let recipient_public_key =
908 identity.with_identity(|authority| Ok(authority.public_key().to_hex()))?;
909 let mut request_filters = BTreeMap::<String, Vec<String>>::new();
910 for action in actions {
911 let action = plan_sync_transport(action, &recipient_public_key)?;
912 match action.kind {
913 SyncActionKind::SendNegOpen
914 | SyncActionKind::SendNegMessage
915 | SyncActionKind::SendNegClose => {
916 if action.frame_json.is_empty() {
917 return Err(SoftchatError::InvalidSyncState);
918 }
919 self.raw_outbound.push_back(action.frame_json);
920 }
921 SyncActionKind::RequestEvents => {
922 if action.filters_json.is_empty() {
923 return Err(SoftchatError::InvalidSyncState);
924 }
925 request_filters
926 .entry(action.subscription_id)
927 .or_default()
928 .extend(action.filters_json);
929 }
930 SyncActionKind::ResendEvents => {
931 if self
932 .sync_resend_backlog
933 .len()
934 .saturating_add(action.event_ids.len())
935 > MAX_SYNC_RESEND_EVENTS
936 {
937 return Err(SoftchatError::RelaySessionQueueFull);
938 }
939 self.sync_resend_backlog.extend(action.event_ids);
940 }
941 SyncActionKind::Complete => {
942 if let Some(active) = &self.sync {
943 let snapshot = active.engine.snapshot();
944 database.record_diagnostic(
945 Diagnostic::new(
946 AccountLogCategory::Synchronization,
947 "sync.completed",
948 "Synchronization checkpoint committed",
949 )
950 .count(
951 Counter::RequestedEvents,
952 u64::from(snapshot.requested_count),
953 )
954 .count(
955 Counter::CommittedEvents,
956 u64::from(snapshot.committed_count),
957 ),
958 );
959 }
960 }
961 SyncActionKind::LoadSnapshot | SyncActionKind::SaveCheckpoint => {
962 return Err(SoftchatError::UnsupportedSystemAction);
963 }
964 }
965 }
966 if request_filters.len() > 1 {
967 return Err(SoftchatError::InvalidSyncState);
968 }
969 for (subscription_id, filters) in request_filters {
970 let parsed = filters
971 .iter()
972 .map(|filter| crate::RelayFilter::from_json(filter))
973 .collect::<Result<Vec<_>, _>>()?;
974 let subscriptions = shard_sync_event_request(&subscription_id, parsed)?;
975 let active = self.sync.as_ref().ok_or(SoftchatError::InvalidSyncState)?;
976 if active.fetch_subscription_base_id.is_some()
977 || !active.fetch_subscription_ids.is_empty()
978 {
979 return Err(SoftchatError::InvalidSyncState);
980 }
981 self.session.subscribe_many(subscriptions.clone())?;
982 let active = self.sync.as_mut().ok_or(SoftchatError::InvalidSyncState)?;
983 active.fetch_subscription_base_id = Some(subscription_id);
984 active.fetch_subscription_ids = subscriptions
985 .into_iter()
986 .map(|(subscription_id, _)| subscription_id)
987 .collect();
988 }
989 if self.raw_outbound.len() > MAX_TRANSPORT_ACTIONS {
990 return Err(SoftchatError::RelaySessionQueueFull);
991 }
992 Ok(())
993 }
994
995 fn commit_delivery_decision(
996 &mut self,
997 database: &mut AccountDatabase,
998 decision: DeliveryDecision,
999 ) -> Result<(), SoftchatError> {
1000 let Some(active) = self.active_delivery.as_mut() else {
1001 return Ok(());
1002 };
1003 if !active.remaining_event_ids.contains(&decision.event_id) {
1004 return Ok(());
1005 }
1006 let intent_id = active
1007 .event_to_intent
1008 .get(&decision.event_id)
1009 .cloned()
1010 .ok_or(SoftchatError::InvalidDeliveryState)?;
1011 let kind = match decision.state {
1012 DeliveryState::Accepted => RelayDeliveryResultKind::Accepted,
1013 DeliveryState::Rejected => RelayDeliveryResultKind::Rejected,
1014 DeliveryState::Retryable => RelayDeliveryResultKind::Retryable,
1015 DeliveryState::Pending | DeliveryState::InFlight => {
1016 return Err(SoftchatError::InvalidDeliveryState);
1017 }
1018 };
1019 database.apply_relay_results(
1020 &active.claim,
1021 vec![RelayDeliveryResult {
1022 intent_id,
1023 kind,
1024 category: decision.category,
1025 }],
1026 )?;
1027 active.remaining_event_ids.remove(&decision.event_id);
1028 if active.remaining_event_ids.is_empty() {
1029 self.active_delivery = None;
1030 }
1031 Ok(())
1032 }
1033
1034 fn prune_typing(&mut self, now: i64) {
1035 self.typing_indicators
1036 .retain(|_, expires_at| *expires_at > now);
1037 }
1038
1039 fn record_typing_indicator(
1040 &mut self,
1041 conversation_id: String,
1042 author_public_key: String,
1043 expires_at: i64,
1044 ) {
1045 let key = (conversation_id, author_public_key);
1046 if !self.typing_indicators.contains_key(&key)
1047 && self.typing_indicators.len() == MAX_TYPING_INDICATORS
1048 && let Some(oldest) = self
1049 .typing_indicators
1050 .iter()
1051 .min_by(|(left_key, left_expiry), (right_key, right_expiry)| {
1052 left_expiry
1053 .cmp(right_expiry)
1054 .then_with(|| left_key.cmp(right_key))
1055 })
1056 .map(|(entry, _)| entry.clone())
1057 {
1058 self.typing_indicators.remove(&oldest);
1059 }
1060 self.typing_indicators.insert(key, expires_at);
1061 }
1062
1063 fn pump(
1064 &mut self,
1065 database: &mut AccountDatabase,
1066 now: i64,
1067 ) -> Result<Vec<AccountTransportAction>, SoftchatError> {
1068 if self
1072 .active_delivery
1073 .as_ref()
1074 .is_some_and(|delivery| now >= delivery.expires_at)
1075 {
1076 return self
1077 .recover_transport(database, RelayFailureKind::Temporary, now)
1078 .map(|batch| batch.actions);
1079 }
1080 let mut actions = Vec::new();
1081 if self.session.snapshot().state == RelayConnectionState::Ready
1082 && self.active_delivery.is_none()
1083 {
1084 let lease_id = self.next_lease_id();
1085 if let Some(claimed) = database.claim_deliveries(
1086 lease_id,
1087 self.relay_url.clone(),
1088 now,
1089 DELIVERY_LEASE_SECONDS,
1090 )? {
1091 self.publish_claim(claimed, now)?;
1092 self.idle = false;
1093 } else {
1094 self.idle = true;
1095 }
1096 }
1097 let send_limit = MAX_TRANSPORT_ACTIONS.saturating_sub(1);
1098 for frame in self.session.drain_outbound(send_limit)? {
1099 actions.push(self.new_action(
1100 AccountTransportActionKind::SendText,
1101 String::new(),
1102 frame.frame,
1103 0,
1104 frame.delivery_event_ids,
1105 ));
1106 }
1107 while actions.len() < send_limit {
1108 let Some(frame) = self.raw_outbound.pop_front() else {
1109 break;
1110 };
1111 actions.push(self.new_action(
1112 AccountTransportActionKind::SendText,
1113 String::new(),
1114 frame,
1115 0,
1116 Vec::new(),
1117 ));
1118 }
1119 while actions.len() < send_limit {
1120 if self.sync_resend_backlog.is_empty() {
1121 break;
1122 }
1123 let count = self
1124 .sync_resend_backlog
1125 .len()
1126 .min(crate::MAX_RELAY_BATCH_EVENTS);
1127 let event_ids = self.sync_resend_backlog.drain(..count).collect();
1128 let events = database.event_jsons(event_ids)?;
1129 let mut frames = crate::session::batch_event_frames(
1130 events
1131 .into_iter()
1132 .filter(|event| {
1136 crate::session::validate_event_frame_json(&event.event_json).is_ok()
1137 })
1138 .map(|event| (event.event_id, event.event_json)),
1139 )?
1140 .into_iter();
1141 while actions.len() < send_limit {
1142 let Some(frame) = frames.next() else {
1143 break;
1144 };
1145 self.sync_resend_event_ids.extend(frame.delivery_event_ids);
1146 actions.push(self.new_action(
1147 AccountTransportActionKind::SendText,
1148 String::new(),
1149 frame.frame,
1150 0,
1151 Vec::new(),
1152 ));
1153 }
1154 for event_id in frames.flat_map(|frame| frame.delivery_event_ids).rev() {
1157 self.sync_resend_backlog.push_front(event_id);
1158 }
1159 }
1160 let receive_pending = self
1161 .pending
1162 .values()
1163 .any(|pending| pending.kind == AccountTransportActionKind::ReceiveText);
1164 if matches!(
1165 self.session.snapshot().state,
1166 RelayConnectionState::Authenticating | RelayConnectionState::Ready
1167 ) && !receive_pending
1168 {
1169 let typing_delay_ms = self
1170 .typing_indicators
1171 .values()
1172 .min()
1173 .map(|expires_at| {
1174 u64::try_from(expires_at.saturating_sub(now))
1175 .unwrap_or_default()
1176 .saturating_mul(1_000)
1177 .clamp(1, 30_000)
1178 })
1179 .unwrap_or(30_000);
1180 actions.push(self.new_action(
1181 AccountTransportActionKind::ReceiveText,
1182 String::new(),
1183 String::new(),
1184 typing_delay_ms,
1185 Vec::new(),
1186 ));
1187 }
1188 if actions.len() > MAX_TRANSPORT_ACTIONS {
1189 return Err(SoftchatError::RelaySessionQueueFull);
1190 }
1191 let sync_complete = self
1192 .sync
1193 .as_ref()
1194 .is_none_or(|active| active.engine.snapshot().phase == SyncPhase::Complete);
1195 self.idle = self.idle
1196 && sync_complete
1197 && self.raw_outbound.is_empty()
1198 && self.sync_resend_backlog.is_empty()
1199 && self.sync_resend_event_ids.is_empty();
1200 Ok(actions)
1201 }
1202
1203 fn publish_claim(&mut self, claimed: ClaimedDelivery, now: i64) -> Result<(), SoftchatError> {
1204 let mut events = Vec::with_capacity(claimed.payloads.len());
1205 let mut event_to_intent = BTreeMap::new();
1206 let mut remaining_event_ids = BTreeSet::new();
1207 for payload in &claimed.payloads {
1208 let event = SignedNostrEvent::from_json(&payload.event_json)?;
1209 let event_id = event.id().to_hex();
1210 if event_to_intent
1211 .insert(event_id.clone(), payload.intent_id.clone())
1212 .is_some()
1213 {
1214 return Err(SoftchatError::InvalidDeliveryState);
1215 }
1216 remaining_event_ids.insert(event_id);
1217 events.push(event);
1218 }
1219 self.session.publish(events)?;
1220 self.active_delivery = Some(ActiveDelivery {
1221 claim: claimed.claim,
1222 event_to_intent,
1223 remaining_event_ids,
1224 socket_written_event_ids: BTreeSet::new(),
1225 expires_at: now
1226 .checked_add(i64::from(DELIVERY_LEASE_SECONDS))
1227 .ok_or(SoftchatError::InvalidDeliveryState)?,
1228 });
1229 self.idle = false;
1230 Ok(())
1231 }
1232
1233 fn recover_transport(
1234 &mut self,
1235 database: &mut AccountDatabase,
1236 failure: RelayFailureKind,
1237 now: i64,
1238 ) -> Result<AccountTransportBatch, SoftchatError> {
1239 self.release_active(database)?;
1240 let mut diagnostic = Diagnostic::new(
1241 AccountLogCategory::Relay,
1242 "relay.retry_scheduled",
1243 "Relay reconnect scheduled",
1244 )
1245 .count(Counter::Attempts, u64::from(self.retry_attempt) + 1);
1246 diagnostic.level = AccountLogLevel::Warn;
1247 diagnostic.summarize = true;
1248 diagnostic.reason = crate::account_diagnostics::relay_failure_reason(failure);
1249 database.record_diagnostic(diagnostic);
1250 if self
1251 .sync
1252 .as_ref()
1253 .is_some_and(|active| active.engine.snapshot().phase != SyncPhase::Complete)
1254 {
1255 database.record_diagnostic(Diagnostic::new(
1256 AccountLogCategory::Synchronization,
1257 "sync.cancelled",
1258 "Synchronization cancelled before completion",
1259 ));
1260 }
1261 self.pending.clear();
1262 self.retired_pending.clear();
1263 self.raw_outbound.clear();
1264 self.sync_resend_backlog.clear();
1265 self.sync_resend_event_ids.clear();
1266 self.typing_indicators.clear();
1267 if let Some(active) = self.sync.as_mut() {
1268 let _ = active.engine.cancel();
1269 }
1270 self.sync = None;
1271 self.authentication_fence = false;
1272 self.authentication_event_id = None;
1273 self.session.transport_lost()?;
1274 self.session = RelaySession::new();
1278 let retry = plan_relay_retry(self.retry_attempt, failure, false);
1279 self.retry_attempt = self.retry_attempt.saturating_add(1);
1280 if retry.action == RelayRetryAction::RotateRelay {
1281 let mutation = database.rotate_failed_relay(self.relay_url.clone(), now)?;
1282 self.relay_url = mutation.active_relay_url;
1283 self.endpoint = parse_relay_endpoint(&self.relay_url)?;
1284 }
1285 self.generation = self
1286 .generation
1287 .checked_add(1)
1288 .ok_or(SoftchatError::InvalidRelaySession)?;
1289 self.session.connect()?;
1290 let action = self.connect_action(retry.delay_ms);
1291 self.batch(vec![action], database, false)
1292 }
1293
1294 fn release_active(&mut self, database: &mut AccountDatabase) -> Result<(), SoftchatError> {
1295 let Some(active) = self.active_delivery.take() else {
1296 return Ok(());
1297 };
1298 if active.remaining_event_ids.is_empty() {
1299 return Ok(());
1300 }
1301 let remaining_intents = active
1302 .remaining_event_ids
1303 .iter()
1304 .filter_map(|event_id| active.event_to_intent.get(event_id))
1305 .cloned()
1306 .collect::<BTreeSet<_>>();
1307 let mut claim = active.claim;
1308 claim
1309 .intents
1310 .retain(|intent| remaining_intents.contains(&intent.intent_id));
1311 claim
1312 .mutations
1313 .retain(|mutation| remaining_intents.contains(&mutation.intent_id));
1314 if !claim.intents.is_empty() {
1315 database.release_claim(&claim)?;
1316 }
1317 Ok(())
1318 }
1319
1320 fn unresolved_intent_ids_for_events(
1321 &self,
1322 event_ids: &[String],
1323 ) -> Result<Vec<String>, SoftchatError> {
1324 if event_ids.is_empty() {
1325 return Ok(Vec::new());
1326 }
1327 let snapshot = self.session.snapshot();
1328 let states = snapshot
1329 .delivery_event_ids
1330 .into_iter()
1331 .zip(snapshot.delivery_states)
1332 .collect::<BTreeMap<_, _>>();
1333 let mut intent_ids = Vec::new();
1334 for event_id in event_ids {
1335 match states.get(event_id) {
1336 Some(DeliveryState::Pending) => {
1337 let active = self
1338 .active_delivery
1339 .as_ref()
1340 .ok_or(SoftchatError::InvalidDeliveryState)?;
1341 if !active.remaining_event_ids.contains(event_id)
1342 || active.socket_written_event_ids.contains(event_id)
1343 {
1344 return Err(SoftchatError::InvalidDeliveryState);
1345 }
1346 intent_ids.push(
1347 active
1348 .event_to_intent
1349 .get(event_id)
1350 .cloned()
1351 .ok_or(SoftchatError::InvalidDeliveryState)?,
1352 );
1353 }
1354 Some(DeliveryState::Retryable) => {
1355 let active = self
1356 .active_delivery
1357 .as_ref()
1358 .ok_or(SoftchatError::InvalidDeliveryState)?;
1359 if !active.remaining_event_ids.contains(event_id)
1360 || !active.socket_written_event_ids.contains(event_id)
1361 {
1362 return Err(SoftchatError::InvalidDeliveryState);
1363 }
1364 }
1365 Some(DeliveryState::Accepted | DeliveryState::Rejected) => {}
1366 Some(DeliveryState::InFlight) | None => {
1367 return Err(SoftchatError::InvalidDeliveryState);
1368 }
1369 }
1370 }
1371 Ok(intent_ids)
1372 }
1373
1374 fn connect_action(&mut self, delay_ms: u64) -> AccountTransportAction {
1375 self.new_action(
1376 AccountTransportActionKind::Connect,
1377 self.endpoint.network_url.clone(),
1378 String::new(),
1379 delay_ms,
1380 Vec::new(),
1381 )
1382 }
1383
1384 fn new_action(
1385 &mut self,
1386 kind: AccountTransportActionKind,
1387 network_url: String,
1388 text_frame: String,
1389 delay_ms: u64,
1390 delivery_event_ids: Vec<String>,
1391 ) -> AccountTransportAction {
1392 self.next_action_sequence = self.next_action_sequence.saturating_add(1);
1393 let action_id = stable_action_id(
1394 &self.run_id,
1395 self.generation,
1396 self.next_action_sequence,
1397 kind,
1398 );
1399 self.pending.insert(
1400 action_id.clone(),
1401 PendingAction {
1402 frame_class: crate::account_diagnostics::planned_frame_class(&text_frame),
1403 kind,
1404 delivery_event_ids,
1405 },
1406 );
1407 AccountTransportAction {
1408 run_id: self.run_id.clone(),
1409 action_id,
1410 generation: self.generation,
1411 kind,
1412 configured_url: if kind == AccountTransportActionKind::Connect {
1413 self.endpoint.configured_url.clone()
1414 } else {
1415 String::new()
1416 },
1417 network_url,
1418 noise_remote_static_key: if kind == AccountTransportActionKind::Connect {
1419 self.endpoint.noise_remote_static_key.clone()
1420 } else {
1421 Vec::new()
1422 },
1423 text_frame,
1424 delay_ms,
1425 }
1426 }
1427
1428 fn next_lease_id(&mut self) -> String {
1429 self.next_action_sequence = self.next_action_sequence.saturating_add(1);
1430 format!(
1431 "transport-{}-{}",
1432 self.generation, self.next_action_sequence
1433 )
1434 }
1435
1436 fn batch(
1437 &self,
1438 actions: Vec<AccountTransportAction>,
1439 database: &AccountDatabase,
1440 idle: bool,
1441 ) -> Result<AccountTransportBatch, SoftchatError> {
1442 let state = self.session.snapshot().state;
1443 if self.diagnostic_state.replace(state) != state {
1444 database.record_diagnostic(crate::account_diagnostics::connection(state));
1445 }
1446 let sync = self.sync.as_ref().map(|active| active.engine.snapshot());
1447 let sync_complete = sync
1448 .as_ref()
1449 .is_none_or(|snapshot| snapshot.phase == SyncPhase::Complete);
1450 Ok(AccountTransportBatch {
1451 actions,
1452 connection_state: state,
1453 idle: (idle || self.idle) && sync_complete,
1454 revision: database.info()?.revision,
1455 typing_indicators: self
1456 .typing_indicators
1457 .iter()
1458 .map(
1459 |((conversation_id, author_public_key), expires_at)| AccountTypingIndicator {
1460 conversation_id: conversation_id.clone(),
1461 author_public_key: author_public_key.clone(),
1462 expires_at: *expires_at,
1463 },
1464 )
1465 .collect(),
1466 sync,
1467 })
1468 }
1469}
1470
1471fn shard_sync_event_request(
1472 base_subscription_id: &str,
1473 filters: Vec<crate::RelayFilter>,
1474) -> Result<Vec<(String, Vec<crate::RelayFilter>)>, SoftchatError> {
1475 if filters.is_empty() {
1476 return Err(SoftchatError::InvalidSyncState);
1477 }
1478 let mut subscriptions = Vec::new();
1479 let mut current = Vec::new();
1480 for filter in filters {
1481 current.push(filter);
1482 let subscription_id =
1483 sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1484 let fits = current.len() <= crate::MAX_RELAY_FILTERS
1485 && crate::ClientRelayFrame::Req {
1486 subscription_id: subscription_id.clone(),
1487 filters: current.clone(),
1488 }
1489 .to_json()
1490 .is_ok();
1491 if fits {
1492 continue;
1493 }
1494 let last = current.pop().ok_or(SoftchatError::InvalidSyncState)?;
1495 if current.is_empty() {
1496 return Err(SoftchatError::InvalidRelayFrame);
1497 }
1498 subscriptions.push((subscription_id, std::mem::take(&mut current)));
1499 current.push(last);
1500 let next_id = sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1501 crate::ClientRelayFrame::Req {
1502 subscription_id: next_id,
1503 filters: current.clone(),
1504 }
1505 .to_json()?;
1506 }
1507 if !current.is_empty() {
1508 let subscription_id =
1509 sync_fetch_subscription_id(base_subscription_id, subscriptions.len())?;
1510 subscriptions.push((subscription_id, current));
1511 }
1512 if subscriptions.len() > crate::MAX_RELAY_SUBSCRIPTIONS {
1513 return Err(SoftchatError::RelaySessionQueueFull);
1514 }
1515 Ok(subscriptions)
1516}
1517
1518#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1519enum InboundFailureDisposition {
1520 Ignore,
1521 Recover(RelayFailureKind),
1522}
1523
1524const fn invalid_incoming_event(error: SoftchatError) -> bool {
1527 matches!(
1528 error,
1529 SoftchatError::InvalidPublicKey
1530 | SoftchatError::InvalidRelayFrame
1531 | SoftchatError::InvalidEventId
1532 | SoftchatError::InvalidEventSignature
1533 | SoftchatError::InvalidEventKind
1534 | SoftchatError::InvalidEventTimestamp
1535 | SoftchatError::InvalidEventTag
1536 | SoftchatError::InvalidEventJson
1537 | SoftchatError::EventTooLarge
1538 | SoftchatError::InvalidNip59Envelope
1539 | SoftchatError::InvalidNip17Message
1540 | SoftchatError::InvalidAttachmentMetadata
1541 | SoftchatError::InvalidSoftchatEvent
1542 | SoftchatError::InvalidSoftchatProjection
1543 | SoftchatError::InvalidUserMetadata
1544 | SoftchatError::InvalidContactList
1545 | SoftchatError::InvalidApplicationData
1546 | SoftchatError::InvalidAccountOperation
1547 | SoftchatError::InvalidAccountSettings
1548 | SoftchatError::DecryptionFailed
1549 )
1550}
1551
1552const fn inbound_failure_disposition(error: SoftchatError) -> Option<InboundFailureDisposition> {
1553 match error {
1554 SoftchatError::InvalidRelayFrame
1555 | SoftchatError::InvalidRelayBatch
1556 | SoftchatError::InvalidRelaySession
1557 | SoftchatError::InvalidDeliveryState => Some(InboundFailureDisposition::Ignore),
1558 SoftchatError::InvalidRelayAuthentication => Some(InboundFailureDisposition::Recover(
1559 RelayFailureKind::Authentication,
1560 )),
1561 SoftchatError::RelaySessionQueueFull => Some(InboundFailureDisposition::Recover(
1562 RelayFailureKind::Temporary,
1563 )),
1564 SoftchatError::InvalidSyncState | SoftchatError::InvalidNegentropy => Some(
1565 InboundFailureDisposition::Recover(RelayFailureKind::ProtocolPermanent),
1566 ),
1567 _ => None,
1568 }
1569}
1570
1571fn sync_fetch_subscription_id(
1572 base_subscription_id: &str,
1573 shard_index: usize,
1574) -> Result<String, SoftchatError> {
1575 let subscription_id = format!("{base_subscription_id}-{shard_index}");
1576 if subscription_id.chars().count() > crate::MAX_SUBSCRIPTION_ID_CHARS {
1577 return Err(SoftchatError::InvalidSyncState);
1578 }
1579 Ok(subscription_id)
1580}
1581
1582pub(crate) fn result_hash(result: &AccountTransportResult) -> Result<String, SoftchatError> {
1583 validate_transport_id(&result.run_id)?;
1584 validate_transport_id(&result.action_id)?;
1585 if result.text_frame.len() > crate::MAX_RELAY_FRAME_BYTES {
1586 return Err(SoftchatError::InvalidRelayFrame);
1587 }
1588 let encoded = serde_json::to_vec(result).map_err(|_| SoftchatError::InternalFailure)?;
1589 Ok(hex::encode(Sha256::digest(encoded)))
1590}
1591
1592fn validate_result_for_action(
1593 result: &AccountTransportResult,
1594 action: AccountTransportActionKind,
1595) -> Result<(), SoftchatError> {
1596 let valid = match action {
1597 AccountTransportActionKind::Connect => matches!(
1598 result.kind,
1599 AccountTransportResultKind::Connected
1600 | AccountTransportResultKind::Disconnected
1601 | AccountTransportResultKind::NetworkUnavailable
1602 | AccountTransportResultKind::AuthenticationFailed
1603 | AccountTransportResultKind::RateLimited
1604 | AccountTransportResultKind::ProtocolFailed
1605 | AccountTransportResultKind::Cancelled
1606 ),
1607 AccountTransportActionKind::SendText => matches!(
1608 result.kind,
1609 AccountTransportResultKind::FrameWritten
1610 | AccountTransportResultKind::Disconnected
1611 | AccountTransportResultKind::NetworkUnavailable
1612 | AccountTransportResultKind::RateLimited
1613 | AccountTransportResultKind::ProtocolFailed
1614 | AccountTransportResultKind::Cancelled
1615 ),
1616 AccountTransportActionKind::ReceiveText => matches!(
1617 result.kind,
1618 AccountTransportResultKind::FrameReceived
1619 | AccountTransportResultKind::TimedOut
1620 | AccountTransportResultKind::Disconnected
1621 | AccountTransportResultKind::NetworkUnavailable
1622 | AccountTransportResultKind::AuthenticationFailed
1623 | AccountTransportResultKind::RateLimited
1624 | AccountTransportResultKind::ProtocolFailed
1625 | AccountTransportResultKind::Cancelled
1626 ),
1627 AccountTransportActionKind::Close => matches!(
1628 result.kind,
1629 AccountTransportResultKind::Disconnected | AccountTransportResultKind::Cancelled
1630 ),
1631 };
1632 if !valid
1633 || (result.kind == AccountTransportResultKind::FrameReceived
1634 && result.text_frame.is_empty())
1635 || (result.kind != AccountTransportResultKind::FrameReceived
1636 && !result.text_frame.is_empty())
1637 {
1638 return Err(SoftchatError::UnsupportedSystemAction);
1639 }
1640 Ok(())
1641}
1642
1643fn stable_action_id(
1644 run_id: &str,
1645 generation: u64,
1646 sequence: u64,
1647 kind: AccountTransportActionKind,
1648) -> String {
1649 let mut digest = Sha256::new();
1650 digest.update(run_id.as_bytes());
1651 digest.update(generation.to_be_bytes());
1652 digest.update(sequence.to_be_bytes());
1653 digest.update([kind as u8]);
1654 hex::encode(digest.finalize())
1655}
1656
1657fn validate_transport_id(value: &str) -> Result<(), SoftchatError> {
1658 if value.is_empty()
1659 || value.len() > MAX_TRANSPORT_ID_BYTES
1660 || !value
1661 .bytes()
1662 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1663 {
1664 return Err(SoftchatError::InvalidRelaySession);
1665 }
1666 Ok(())
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 use super::*;
1672
1673 const NOW: i64 = 1_700_000_000;
1674
1675 fn authority() -> Result<(crate::LocalIdentity, LocalIdentityHandle), SoftchatError> {
1676 let mut secret = vec![0; 32];
1677 secret[31] = 1;
1678 Ok((
1679 crate::LocalIdentity::from_secret_bytes(&secret)?,
1680 LocalIdentityHandle::new(secret)?,
1681 ))
1682 }
1683
1684 fn result(
1685 action: &AccountTransportAction,
1686 kind: AccountTransportResultKind,
1687 text_frame: String,
1688 ) -> AccountTransportResult {
1689 AccountTransportResult {
1690 run_id: action.run_id.clone(),
1691 action_id: action.action_id.clone(),
1692 generation: action.generation,
1693 kind,
1694 text_frame,
1695 }
1696 }
1697
1698 fn action(
1699 batch: &AccountTransportBatch,
1700 kind: AccountTransportActionKind,
1701 ) -> Result<AccountTransportAction, SoftchatError> {
1702 batch
1703 .actions
1704 .iter()
1705 .find(|action| action.kind == kind)
1706 .cloned()
1707 .ok_or(SoftchatError::UnsupportedSystemAction)
1708 }
1709
1710 fn connected_run(
1711 database: &mut AccountDatabase,
1712 identity: &LocalIdentityHandle,
1713 ) -> Result<(AccountTransportRun, AccountTransportBatch), SoftchatError> {
1714 let (mut run, started) = AccountTransportRun::start(
1715 "transport-regression".to_owned(),
1716 vec!["{}".to_owned()],
1717 database,
1718 )?;
1719 let batch = run.handle_result(
1720 &result(
1721 &started.actions[0],
1722 AccountTransportResultKind::Connected,
1723 String::new(),
1724 ),
1725 database,
1726 identity,
1727 NOW,
1728 )?;
1729 Ok((run, batch))
1730 }
1731
1732 fn signed_note(
1733 identity: &crate::LocalIdentity,
1734 timestamp: u64,
1735 text: String,
1736 ) -> Result<SignedNostrEvent, SoftchatError> {
1737 identity.sign_event(crate::NostrEventDraft::new(
1738 timestamp,
1739 crate::NostrEventKind::SHORT_TEXT_NOTE,
1740 Vec::new(),
1741 text,
1742 )?)
1743 }
1744
1745 fn invalid_wrapper(identity: &crate::LocalIdentity) -> Result<SignedNostrEvent, SoftchatError> {
1746 identity.sign_event(crate::NostrEventDraft::new(
1747 NOW as u64,
1748 crate::NostrEventKind::GIFT_WRAP,
1749 vec![crate::NostrTag::new(vec![
1750 "p".to_owned(),
1751 identity.public_key().to_hex(),
1752 ])?],
1753 "invalid ciphertext",
1754 )?)
1755 }
1756
1757 fn reconcile_remote(
1758 run: &mut AccountTransportRun,
1759 connected: &AccountTransportBatch,
1760 database: &mut AccountDatabase,
1761 identity: &LocalIdentityHandle,
1762 events: &[SignedNostrEvent],
1763 ) -> Result<AccountTransportBatch, Box<dyn std::error::Error>> {
1764 let open = connected
1765 .actions
1766 .iter()
1767 .find_map(|action| {
1768 let values =
1769 serde_json::from_str::<Vec<serde_json::Value>>(&action.text_frame).ok()?;
1770 (values.first()?.as_str()? == "NEG-OPEN").then_some(values)
1771 })
1772 .ok_or("missing NEG-OPEN")?;
1773 let subscription = open[1].as_str().ok_or("missing subscription")?;
1774 let initial = hex::decode(
1775 open.last()
1776 .and_then(serde_json::Value::as_str)
1777 .ok_or("missing message")?,
1778 )?;
1779 let mut storage = softrelay_negentropy::NegentropyStorageVector::new();
1780 for event in events {
1781 storage.insert(
1782 event.created_at(),
1783 softrelay_negentropy::Id::from_byte_array(*event.id().as_inner().as_bytes()),
1784 )?;
1785 }
1786 storage.seal()?;
1787 let mut relay = softrelay_negentropy::Negentropy::owned(storage, SYNC_FRAME_SIZE_LIMIT)?;
1788 let response = relay.reconcile(&initial)?;
1789 Ok(run.handle_result(
1790 &result(
1791 &action(connected, AccountTransportActionKind::ReceiveText)?,
1792 AccountTransportResultKind::FrameReceived,
1793 serde_json::json!(["NEG-MSG", subscription, hex::encode(response)]).to_string(),
1794 ),
1795 database,
1796 identity,
1797 NOW + 1,
1798 )?)
1799 }
1800
1801 #[test]
1802 fn transport_invalid_inner_event_does_not_poison_live_ingestion()
1803 -> Result<(), Box<dyn std::error::Error>> {
1804 let (identity, handle) = authority()?;
1805 let mut database = AccountDatabase::open(":memory:", "live-invalid".to_owned())?;
1806 let (mut run, connected) = connected_run(&mut database, &handle)?;
1807 let malformed_rumor = identity.create_rumor(crate::NostrEventDraft::new(
1808 NOW as u64,
1809 crate::NostrEventKind::REACTION,
1810 Vec::new(),
1811 "+",
1812 )?)?;
1813 let bad_events = [
1814 invalid_wrapper(&identity)?,
1815 identity.gift_wrap(
1816 &identity.public_key(),
1817 &malformed_rumor,
1818 crate::Nip59EnvelopeKind::Durable,
1819 NOW as u64,
1820 )?,
1821 ];
1822 let before = database.info()?.revision;
1823 let mut after = connected;
1824 for bad in bad_events {
1825 after = run.handle_result(
1826 &result(
1827 &action(&after, AccountTransportActionKind::ReceiveText)?,
1828 AccountTransportResultKind::FrameReceived,
1829 RelayResponseFrame::Event {
1830 subscription_id: "softchat-live-0".to_owned(),
1831 event: bad.clone(),
1832 }
1833 .to_json()?,
1834 ),
1835 &mut database,
1836 &handle,
1837 NOW + 1,
1838 )?;
1839 assert_eq!(after.connection_state, RelayConnectionState::Ready);
1840 assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1841 assert_eq!(database.info()?.revision, before);
1842 assert!(database.event_json(&bad.id().to_hex()).is_err());
1843 }
1844 let valid = signed_note(&identity, NOW as u64, "valid next event".to_owned())?;
1845 run.handle_result(
1846 &result(
1847 &action(&after, AccountTransportActionKind::ReceiveText)?,
1848 AccountTransportResultKind::FrameReceived,
1849 RelayResponseFrame::Event {
1850 subscription_id: "softchat-live-0".to_owned(),
1851 event: valid.clone(),
1852 }
1853 .to_json()?,
1854 ),
1855 &mut database,
1856 &handle,
1857 NOW + 2,
1858 )?;
1859 assert_eq!(database.event_json(&valid.id().to_hex())?, valid.to_json()?);
1860 assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1861 handle.erase();
1864 assert_eq!(
1865 run.process_inbound_frame(
1866 &RelayResponseFrame::Event {
1867 subscription_id: "softchat-live-0".to_owned(),
1868 event: valid,
1869 }
1870 .to_json()?,
1871 &mut database,
1872 &handle,
1873 NOW + 3
1874 ),
1875 Err(SoftchatError::IdentityErased)
1876 );
1877 Ok(())
1878 }
1879
1880 #[test]
1881 fn transport_invalid_sync_event_recovers_without_advancing_checkpoint()
1882 -> Result<(), Box<dyn std::error::Error>> {
1883 let (identity, handle) = authority()?;
1884 let mut database = AccountDatabase::open(":memory:", "sync-invalid".to_owned())?;
1885 let (mut run, connected) = connected_run(&mut database, &handle)?;
1886 let bad = invalid_wrapper(&identity)?;
1887 let fetching = reconcile_remote(
1888 &mut run,
1889 &connected,
1890 &mut database,
1891 &handle,
1892 std::slice::from_ref(&bad),
1893 )?;
1894 let subscription = run
1895 .sync
1896 .as_ref()
1897 .and_then(|sync| sync.fetch_subscription_ids.first())
1898 .cloned()
1899 .ok_or("missing fetch")?;
1900 let recovered = run.handle_result(
1901 &result(
1902 &action(&fetching, AccountTransportActionKind::ReceiveText)?,
1903 AccountTransportResultKind::FrameReceived,
1904 RelayResponseFrame::Event {
1905 subscription_id: subscription,
1906 event: bad.clone(),
1907 }
1908 .to_json()?,
1909 ),
1910 &mut database,
1911 &handle,
1912 NOW + 2,
1913 )?;
1914 assert_eq!(recovered.connection_state, RelayConnectionState::Connecting);
1915 assert!(
1916 action(&recovered, AccountTransportActionKind::Connect)?.generation
1917 > connected.actions[0].generation
1918 );
1919 assert_eq!(database.sync_checkpoint(SYNC_ID)?, None);
1920 assert!(database.event_json(&bad.id().to_hex()).is_err());
1921 assert!(run.session.snapshot().pending_ingestion_ids.is_empty());
1922 Ok(())
1923 }
1924
1925 #[test]
1926 fn transport_retryable_ack_preserves_terminal_outcomes_and_reclaims_once()
1927 -> Result<(), Box<dyn std::error::Error>> {
1928 for (count, written) in [(3, true), (4, false)] {
1931 let (identity, handle) = authority()?;
1932 let mut database = AccountDatabase::open(":memory:", "retryable-ack".to_owned())?;
1933 let events = (0..count)
1934 .map(|offset| signed_note(&identity, NOW as u64 + offset, "outgoing".to_owned()))
1935 .collect::<Result<Vec<_>, _>>()?;
1936 database.enqueue_signed(
1937 &identity,
1938 "retry-operation".to_owned(),
1939 events.iter().map(crate::event::SignedEvent::from).collect(),
1940 vec![database.active_relay_url()?],
1941 NOW,
1942 )?;
1943 let (mut run, connected) = connected_run(&mut database, &handle)?;
1944 let send = connected
1945 .actions
1946 .iter()
1947 .find(|action| {
1948 run.pending
1949 .get(&action.action_id)
1950 .is_some_and(|pending| !pending.delivery_event_ids.is_empty())
1951 })
1952 .cloned()
1953 .ok_or("missing delivery send")?;
1954 if written {
1955 run.handle_result(
1956 &result(
1957 &send,
1958 AccountTransportResultKind::FrameWritten,
1959 String::new(),
1960 ),
1961 &mut database,
1962 &handle,
1963 NOW + 1,
1964 )?;
1965 }
1966 let mut receive = action(&connected, AccountTransportActionKind::ReceiveText)?;
1967 for (index, accepted, message) in [
1968 (0, true, ""),
1969 (1, false, "blocked: policy"),
1970 (2, false, "rate-limited: slow down"),
1971 ] {
1972 let batch = run.handle_result(
1973 &result(
1974 &receive,
1975 AccountTransportResultKind::FrameReceived,
1976 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
1977 event_id: events[index].id().to_hex(),
1978 accepted,
1979 message: message.to_owned(),
1980 })
1981 .to_json()?,
1982 ),
1983 &mut database,
1984 &handle,
1985 NOW + 2 + index as i64,
1986 )?;
1987 if index < 2 {
1988 receive = action(&batch, AccountTransportActionKind::ReceiveText)?;
1989 } else {
1990 let reconnect = action(&batch, AccountTransportActionKind::Connect)?;
1991 assert!(
1992 reconnect.delay_ms > 0
1993 && reconnect.delay_ms <= crate::MAX_RELAY_RETRY_DELAY_MS
1994 );
1995 let snapshot = database.operation("retry-operation")?;
1996 assert_eq!(
1997 (
1998 snapshot.accepted_intents,
1999 snapshot.rejected_intents,
2000 snapshot.queued_intents
2001 ),
2002 (1, 1, u32::try_from(count - 2)?)
2003 );
2004 let ready = run.handle_result(
2005 &result(
2006 &reconnect,
2007 AccountTransportResultKind::Connected,
2008 String::new(),
2009 ),
2010 &mut database,
2011 &handle,
2012 NOW + 5,
2013 )?;
2014 let resent = ready
2015 .actions
2016 .iter()
2017 .filter_map(|action| run.pending.get(&action.action_id))
2018 .flat_map(|pending| pending.delivery_event_ids.clone())
2019 .collect::<Vec<_>>();
2020 assert_eq!(resent.len(), usize::try_from(count - 2)?);
2021 assert_eq!(
2022 resent.into_iter().collect::<BTreeSet<_>>(),
2023 events[2..]
2024 .iter()
2025 .map(|event| event.id().to_hex())
2026 .collect()
2027 );
2028 run.handle_result(
2029 &result(
2030 &send,
2031 AccountTransportResultKind::FrameWritten,
2032 String::new(),
2033 ),
2034 &mut database,
2035 &handle,
2036 NOW + 6,
2037 )?;
2038 }
2039 }
2040 }
2041 Ok(())
2042 }
2043
2044 #[test]
2045 fn transport_delivery_expiry_is_not_postponed_by_inbound_traffic()
2046 -> Result<(), Box<dyn std::error::Error>> {
2047 enum Progress {
2048 Notice,
2049 Wake,
2050 Accepted,
2051 }
2052 for progress in [Progress::Notice, Progress::Wake, Progress::Accepted] {
2053 let (identity, handle) = authority()?;
2054 let mut database = AccountDatabase::open(":memory:", "busy-expiry".to_owned())?;
2055 let event = signed_note(&identity, NOW as u64, "awaiting acknowledgement".to_owned())?;
2056 database.enqueue_signed(
2057 &identity,
2058 "busy-operation".to_owned(),
2059 vec![crate::event::SignedEvent::from(&event)],
2060 vec![database.active_relay_url()?],
2061 NOW,
2062 )?;
2063 let (mut run, connected) = connected_run(&mut database, &handle)?;
2064 let send = connected
2065 .actions
2066 .iter()
2067 .find(|action| {
2068 run.pending
2069 .get(&action.action_id)
2070 .is_some_and(|pending| !pending.delivery_event_ids.is_empty())
2071 })
2072 .ok_or("missing send")?;
2073 run.handle_result(
2074 &result(
2075 send,
2076 AccountTransportResultKind::FrameWritten,
2077 String::new(),
2078 ),
2079 &mut database,
2080 &handle,
2081 NOW + 1,
2082 )?;
2083 let busy = run.handle_result(
2084 &result(
2085 &action(&connected, AccountTransportActionKind::ReceiveText)?,
2086 AccountTransportResultKind::FrameReceived,
2087 RelayResponseFrame::Notice("still connected".to_owned()).to_json()?,
2088 ),
2089 &mut database,
2090 &handle,
2091 NOW + 20,
2092 )?;
2093 assert_eq!(busy.connection_state, RelayConnectionState::Ready);
2094 let expired = match progress {
2095 Progress::Wake => run.wake(&mut database, NOW + 31)?,
2096 Progress::Notice | Progress::Accepted => {
2097 let frame = if matches!(progress, Progress::Accepted) {
2098 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
2099 event_id: event.id().to_hex(),
2100 accepted: true,
2101 message: String::new(),
2102 })
2103 } else {
2104 RelayResponseFrame::Notice("still busy".to_owned())
2105 };
2106 run.handle_result(
2107 &result(
2108 &action(&busy, AccountTransportActionKind::ReceiveText)?,
2109 AccountTransportResultKind::FrameReceived,
2110 frame.to_json()?,
2111 ),
2112 &mut database,
2113 &handle,
2114 NOW + 31,
2115 )?
2116 }
2117 };
2118 let operation = database.operation("busy-operation")?;
2119 if matches!(progress, Progress::Accepted) {
2120 assert_eq!(operation.state, crate::OperationState::Sent);
2121 assert_eq!(expired.connection_state, RelayConnectionState::Ready);
2122 } else {
2123 let reconnect = action(&expired, AccountTransportActionKind::Connect)?;
2124 assert!(reconnect.generation > connected.actions[0].generation);
2125 assert!(reconnect.delay_ms > 0);
2126 assert_eq!((operation.queued_intents, operation.active_intents), (1, 0));
2127 }
2128 }
2129 Ok(())
2130 }
2131
2132 #[test]
2133 fn transport_sync_accepts_a_maximum_cohort_independent_of_chunk_boundaries()
2134 -> Result<(), Box<dyn std::error::Error>> {
2135 let (_, handle) = authority()?;
2136 let mut database = AccountDatabase::open(":memory:", "sync-max".to_owned())?;
2137 let (mut run, _) = connected_run(&mut database, &handle)?;
2138 let ids = (0..crate::SYNC_MAX_PAGE_LIMIT)
2139 .map(|index| format!("{index:064x}"))
2140 .collect::<Vec<_>>();
2141 let resend = |ids: Vec<String>| SyncAction {
2142 kind: SyncActionKind::ResendEvents,
2143 event_ids: ids,
2144 subscription_id: String::new(),
2145 since_timestamp: -1,
2146 until_timestamp: -1,
2147 limit: 0,
2148 message: Vec::new(),
2149 frame_json: String::new(),
2150 filters_json: Vec::new(),
2151 checkpoint_timestamp: -1,
2152 };
2153 run.apply_sync_actions(
2154 ids.chunks(crate::SYNC_EVENT_REQUEST_CHUNK)
2155 .map(|chunk| resend(chunk.to_vec()))
2156 .collect(),
2157 &mut database,
2158 &handle,
2159 )?;
2160 assert_eq!(
2161 run.sync_resend_backlog.iter().cloned().collect::<Vec<_>>(),
2162 ids
2163 );
2164 assert_eq!(
2165 run.apply_sync_actions(vec![resend(vec!["ff".repeat(32)])], &mut database, &handle),
2166 Err(SoftchatError::RelaySessionQueueFull)
2167 );
2168 assert_eq!(run.sync_resend_backlog.len(), ids.len());
2169 Ok(())
2170 }
2171
2172 #[test]
2173 fn transport_sync_resends_fit_frame_bytes_and_keep_every_event()
2174 -> Result<(), Box<dyn std::error::Error>> {
2175 let (identity, handle) = authority()?;
2176 let mut database = AccountDatabase::open(":memory:", "sync-large".to_owned())?;
2177 let events = (0..3)
2178 .map(|offset| signed_note(&identity, NOW as u64 - offset, "x".repeat(280 * 1024)))
2179 .collect::<Result<Vec<_>, _>>()?;
2180 for event in &events {
2181 database.ingest(&identity, vec![crate::event::SignedEvent::from(event)], NOW)?;
2182 }
2183 let (mut run, connected) = connected_run(&mut database, &handle)?;
2184 for index in 0..MAX_TRANSPORT_ACTIONS - 3 {
2187 run.raw_outbound.push_back(
2188 crate::ClientRelayFrame::Close {
2189 subscription_id: format!("finished-{index}"),
2190 }
2191 .to_json()?,
2192 );
2193 }
2194 let resent = reconcile_remote(&mut run, &connected, &mut database, &handle, &[])?;
2195 assert!(!run.sync_resend_backlog.is_empty());
2196 let next = run.wake(&mut database, NOW + 2)?;
2197 assert!(run.sync_resend_backlog.is_empty());
2198 let mut actual = Vec::new();
2199 for action in resent
2200 .actions
2201 .iter()
2202 .chain(&next.actions)
2203 .filter(|action| action.kind == AccountTransportActionKind::SendText)
2204 {
2205 assert!(
2206 action.text_frame.len() <= crate::MAX_RELAY_FRAME_BYTES,
2207 "oversized resend: {} bytes",
2208 action.text_frame.len()
2209 );
2210 let values: Vec<serde_json::Value> = serde_json::from_str(&action.text_frame)?;
2211 if matches!(values[0].as_str(), Some("EVENT" | "EVENTS")) {
2212 actual.extend(
2213 values[1..]
2214 .iter()
2215 .filter_map(|event| event["id"].as_str().map(str::to_owned)),
2216 );
2217 }
2218 }
2219 assert_eq!(actual.len(), events.len());
2220 assert_eq!(
2221 actual.into_iter().collect::<BTreeSet<_>>(),
2222 events.iter().map(|event| event.id().to_hex()).collect()
2223 );
2224 Ok(())
2225 }
2226
2227 #[test]
2228 fn maximum_sync_request_is_sharded_into_valid_relay_frames() -> Result<(), SoftchatError> {
2229 let filters = (0..200_u64)
2230 .map(|chunk| crate::RelayFilter {
2231 ids: (0..crate::SYNC_EVENT_REQUEST_CHUNK)
2232 .map(|offset| {
2233 let value = chunk
2234 .saturating_mul(crate::SYNC_EVENT_REQUEST_CHUNK as u64)
2235 .saturating_add(offset as u64);
2236 format!("{value:064x}")
2237 })
2238 .collect(),
2239 ..crate::RelayFilter::default()
2240 })
2241 .collect::<Vec<_>>();
2242
2243 let subscriptions = shard_sync_event_request("softchat-events-1", filters)?;
2244 assert!(subscriptions.len() > 1);
2245 assert_eq!(
2246 subscriptions
2247 .iter()
2248 .map(|(_, filters)| filters.len())
2249 .sum::<usize>(),
2250 200,
2251 );
2252 assert_eq!(
2253 subscriptions
2254 .iter()
2255 .map(|(subscription_id, _)| subscription_id)
2256 .collect::<BTreeSet<_>>()
2257 .len(),
2258 subscriptions.len(),
2259 );
2260 for adjacent in subscriptions.windows(2) {
2261 let mut over_boundary = adjacent[0].1.clone();
2262 over_boundary.push(
2263 adjacent[1]
2264 .1
2265 .first()
2266 .cloned()
2267 .ok_or(SoftchatError::InvalidSyncState)?,
2268 );
2269 let exceeds_count = over_boundary.len() > crate::MAX_RELAY_FILTERS;
2270 let exceeds_bytes = crate::ClientRelayFrame::Req {
2271 subscription_id: adjacent[0].0.clone(),
2272 filters: over_boundary,
2273 }
2274 .to_json()
2275 .is_err();
2276 assert!(exceeds_count || exceeds_bytes);
2277 }
2278 for (subscription_id, filters) in subscriptions {
2279 assert!(filters.len() <= crate::MAX_RELAY_FILTERS);
2280 let frame = crate::ClientRelayFrame::Req {
2281 subscription_id,
2282 filters,
2283 }
2284 .to_json()?;
2285 assert!(frame.len() <= crate::MAX_RELAY_FRAME_BYTES);
2286 }
2287 Ok(())
2288 }
2289}