-
-
Notifications
You must be signed in to change notification settings - Fork 127
fix: address 9 audit findings across correctness, safety, and performance #460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b1acfcb
b950542
782515c
a65d796
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -873,25 +873,36 @@ impl Client { | |
| if let Err(connect_err) = self.connect().await { | ||
| error!("Failed to connect: {connect_err:#}. Will retry..."); | ||
| } else { | ||
| if self.read_messages_loop().await.is_err() { | ||
| let unexpected_disconnect = if self.read_messages_loop().await.is_err() { | ||
| // Check intentional_reconnect AFTER read loop exits — reconnect() | ||
| // sets this flag while the loop is running, so it must be read here. | ||
| if self.expected_disconnect.load(Ordering::Relaxed) | ||
| || self.intentional_reconnect.swap(false, Ordering::Relaxed) | ||
| { | ||
| debug!("Message loop exited during expected disconnect."); | ||
| false | ||
| } else { | ||
| warn!( | ||
| "Message loop exited with an error. Will attempt to reconnect if enabled." | ||
| ); | ||
| true | ||
| } | ||
| } else if self.expected_disconnect.load(Ordering::Relaxed) { | ||
| debug!("Message loop exited gracefully (expected disconnect)."); | ||
| false | ||
| } else { | ||
| info!("Message loop exited gracefully."); | ||
| } | ||
| false | ||
| }; | ||
|
|
||
| self.cleanup_connection_state().await; | ||
|
|
||
| // Dispatch after cleanup so handlers see cleared connection state. | ||
| if unexpected_disconnect { | ||
| self.core | ||
| .event_bus | ||
| .dispatch(&Event::Disconnected(crate::types::events::Disconnected)); | ||
| } | ||
| } | ||
|
|
||
| if !self.enable_auto_reconnect.load(Ordering::Relaxed) { | ||
|
|
@@ -1255,7 +1266,7 @@ impl Client { | |
| Ok(crate::transport::TransportEvent::DataReceived(data)) => { | ||
| // Update dead-socket timer (WA Web: deadSocketTimer reset) | ||
| self.last_data_received_ms.store( | ||
| wacore::time::now_millis() as u64, | ||
| wacore::time::now_millis().max(0) as u64, | ||
| Ordering::Relaxed, | ||
| ); | ||
|
|
||
|
|
@@ -1308,9 +1319,7 @@ impl Client { | |
| } | ||
| }, | ||
| Ok(crate::transport::TransportEvent::Disconnected) | Err(_) => { | ||
| self.cleanup_connection_state().await; | ||
| if !self.expected_disconnect.load(Ordering::Relaxed) { | ||
| self.core.event_bus.dispatch(&Event::Disconnected(crate::types::events::Disconnected)); | ||
| if !self.expected_disconnect.load(Ordering::Relaxed) { | ||
| debug!("Transport disconnected unexpectedly."); | ||
|
Comment on lines
+1322
to
1323
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| return Err(anyhow::anyhow!("Transport disconnected unexpectedly")); | ||
| } else { | ||
|
|
@@ -3318,7 +3327,7 @@ impl Client { | |
|
|
||
| // WA Web: callStanza → deadSocketTimer.onOrBefore(deadSocketTime, socketId) | ||
| self.last_data_sent_ms | ||
| .store(wacore::time::now_millis() as u64, Ordering::Relaxed); | ||
| .store(wacore::time::now_millis().max(0) as u64, Ordering::Relaxed); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -294,73 +294,95 @@ impl SignalStoreCache { | |
|
|
||
| // === Flush === | ||
|
|
||
| /// Flush all dirty state to the backend in a single batch. | ||
| /// Acquires all 3 mutexes to ensure consistency (matches WhatsApp Web's pattern). | ||
| /// Flush all dirty state to the backend. | ||
| /// | ||
| /// Sessions are serialized here (not on every store_session call). | ||
| /// Dirty sets are only cleared after ALL writes succeed. | ||
| /// Each store (sessions, identities, sender_keys) is flushed independently | ||
| /// under its own lock. This means: | ||
| /// - Only ONE store is locked during its I/O — the other two are free for | ||
| /// concurrent encrypt/decrypt operations. | ||
| /// - No race between snapshot and clear — the lock is held throughout, so | ||
| /// mutations to the same store are blocked until the flush completes. | ||
| /// - Dirty sets are cleared only after successful writes. | ||
| pub async fn flush(&self, backend: &dyn SignalStore) -> Result<()> { | ||
| let mut sessions = self.sessions.lock().await; | ||
| let mut identities = self.identities.lock().await; | ||
| let mut sender_keys = self.sender_keys.lock().await; | ||
|
|
||
| // Snapshot dirty/deleted sets WITHOUT draining — preserve on failure | ||
| let session_dirty: Vec<_> = sessions.dirty.iter().cloned().collect(); | ||
| let session_deleted: Vec<_> = sessions.deleted.iter().cloned().collect(); | ||
| let identity_dirty: Vec<_> = identities.dirty.iter().cloned().collect(); | ||
| let identity_deleted: Vec<_> = identities.deleted.iter().cloned().collect(); | ||
| let sender_key_dirty: Vec<_> = sender_keys.dirty.iter().cloned().collect(); | ||
|
|
||
| // Persist dirty sessions — serialize only here, not on every store_session | ||
| for address in &session_dirty { | ||
| if let Some(Some(record)) = sessions.cache.get(address.as_ref()) { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?; | ||
| backend.put_session(address, &bytes).await?; | ||
| // Flush sessions | ||
| { | ||
| let mut state = self.sessions.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect(); | ||
|
|
||
| for address in &dirty_keys { | ||
| if let Some(Some(record)) = state.cache.get(address.as_ref()) { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("session serialize for {address}: {e}"))?; | ||
| backend.put_session(address, &bytes).await?; | ||
| } | ||
| } | ||
| for address in &deleted_keys { | ||
| backend.delete_session(address).await?; | ||
| } | ||
| } | ||
| for address in &session_deleted { | ||
| backend.delete_session(address).await?; | ||
| } | ||
|
|
||
| for address in &identity_dirty { | ||
| if let Some(Some(data)) = identities.cache.get(address.as_ref()) { | ||
| let key: [u8; 32] = data.as_ref().try_into().map_err(|_| { | ||
| anyhow::anyhow!( | ||
| "Corrupted identity key for {address}: expected 32 bytes, got {}", | ||
| data.len() | ||
| ) | ||
| })?; | ||
| backend.put_identity(address, key).await?; | ||
| for key in &dirty_keys { | ||
| state.dirty.remove(key); | ||
| } | ||
| for key in &deleted_keys { | ||
| state.deleted.remove(key); | ||
| } | ||
| } | ||
| for address in &identity_deleted { | ||
| backend.delete_identity(address).await?; | ||
| } | ||
|
|
||
| for name in &sender_key_dirty { | ||
| match sender_keys.cache.get(name.as_ref()) { | ||
| Some(Some(record)) => { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?; | ||
| backend.put_sender_key(name, &bytes).await?; | ||
| } | ||
| Some(None) => { | ||
| // Deleted via delete_sender_key — propagate to backend | ||
| backend.delete_sender_key(name).await?; | ||
| // Flush identities | ||
| { | ||
| let mut state = self.identities.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect(); | ||
|
|
||
| for address in &dirty_keys { | ||
| if let Some(Some(data)) = state.cache.get(address.as_ref()) { | ||
| let key: [u8; 32] = data.as_ref().try_into().map_err(|_| { | ||
| anyhow::anyhow!( | ||
| "Corrupted identity key for {address}: expected 32 bytes, got {}", | ||
| data.len() | ||
| ) | ||
| })?; | ||
| backend.put_identity(address, key).await?; | ||
| } | ||
| None => {} | ||
| } | ||
| for address in &deleted_keys { | ||
| backend.delete_identity(address).await?; | ||
| } | ||
|
|
||
| for key in &dirty_keys { | ||
| state.dirty.remove(key); | ||
| } | ||
| for key in &deleted_keys { | ||
| state.deleted.remove(key); | ||
| } | ||
| } | ||
|
|
||
| // All writes succeeded — clear dirty sets (matches WA Web's clearDirty()) | ||
| sessions.dirty.clear(); | ||
| sessions.deleted.clear(); | ||
| identities.dirty.clear(); | ||
| identities.deleted.clear(); | ||
| sender_keys.dirty.clear(); | ||
| // Flush sender keys | ||
| { | ||
| let mut state = self.sender_keys.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
|
|
||
| for name in &dirty_keys { | ||
| match state.cache.get(name.as_ref()) { | ||
| Some(Some(record)) => { | ||
| let bytes = record | ||
| .serialize() | ||
| .map_err(|e| anyhow::anyhow!("sender key serialize for {name}: {e}"))?; | ||
| backend.put_sender_key(name, &bytes).await?; | ||
| } | ||
| Some(None) => { | ||
| backend.delete_sender_key(name).await?; | ||
| } | ||
| None => {} | ||
| } | ||
| } | ||
|
|
||
| for key in &dirty_keys { | ||
| state.dirty.remove(key); | ||
| } | ||
| } | ||
|
|
||
| Ok(()) | ||
|
Comment on lines
306
to
387
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Note partial-failure semantics for future reference. If, say, sessions flush succeeds but identities flush fails, the sessions dirty set is cleared before the error is returned. On retry, only identities (and sender_keys) will be re-flushed since sessions are already persisted and no longer dirty. This is correct behavior since the session writes did succeed. This differs slightly from the PR description's "clearing dirty sets only after all writes succeed" (which implies a global all-or-nothing), but per-store clearing is the more practical approach given the independent store design. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
intentional_reconnectis folded into the “expected” path here, which makesunexpected_disconnectfalse and skips the laterEvent::Disconnecteddispatch inrun(). In practice, callingreconnect()now drops the transport without emitting a disconnect event, so consumers that rely onDisconnectedcallbacks (e.g., reconnect-state/UI transitions) will miss that lifecycle transition even though the socket was torn down.Useful? React with 👍 / 👎.