-
-
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 2 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 |
|---|---|---|
|
|
@@ -295,72 +295,119 @@ 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). | ||
| /// | ||
| /// Sessions are serialized here (not on every store_session call). | ||
| /// Dirty sets are only cleared after ALL writes succeed. | ||
| /// Uses a snapshot-then-release pattern: serialize dirty data under the lock, | ||
| /// release locks, then write to the backend. This avoids blocking all | ||
| /// encrypt/decrypt operations for the duration of I/O. | ||
| /// | ||
| /// Dirty sets are only cleared after ALL writes succeed, preserving retry | ||
| /// semantics on partial failure. | ||
| 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?; | ||
| // Phase 1: snapshot + serialize under lock, then release. | ||
| let (session_writes, session_delete_keys, session_dirty_keys) = { | ||
| let state = self.sessions.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect(); | ||
| let mut writes = Vec::with_capacity(dirty_keys.len()); | ||
| 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}"))?; | ||
| writes.push((address.clone(), bytes)); | ||
| } | ||
| } | ||
| (writes, deleted_keys, dirty_keys) | ||
| }; | ||
|
|
||
| let (identity_writes, identity_delete_keys, identity_dirty_keys) = { | ||
| let state = self.identities.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
| let deleted_keys: Vec<_> = state.deleted.iter().cloned().collect(); | ||
| let mut writes = Vec::with_capacity(dirty_keys.len()); | ||
| 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() | ||
| ) | ||
| })?; | ||
| writes.push((address.clone(), key)); | ||
| } | ||
| } | ||
| (writes, deleted_keys, dirty_keys) | ||
| }; | ||
|
|
||
| let (sender_key_ops, sender_key_dirty_keys) = { | ||
| let state = self.sender_keys.lock().await; | ||
| let dirty_keys: Vec<_> = state.dirty.iter().cloned().collect(); | ||
| let mut ops: Vec<(Arc<str>, Option<Vec<u8>>)> = Vec::with_capacity(dirty_keys.len()); | ||
| 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}"))?; | ||
| ops.push((name.clone(), Some(bytes))); | ||
| } | ||
| Some(None) => { | ||
| ops.push((name.clone(), None)); | ||
| } | ||
| None => {} | ||
| } | ||
| } | ||
| (ops, dirty_keys) | ||
| }; | ||
|
|
||
| // Phase 2: write to backend without holding any locks. | ||
| for (address, bytes) in &session_writes { | ||
| backend.put_session(address, bytes).await?; | ||
| } | ||
| for address in &session_deleted { | ||
| for address in &session_delete_keys { | ||
| 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 (address, key) in &identity_writes { | ||
| backend.put_identity(address, *key).await?; | ||
| } | ||
| for address in &identity_deleted { | ||
| for address in &identity_delete_keys { | ||
| 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?; | ||
| } | ||
| None => {} | ||
| for (name, bytes_opt) in &sender_key_ops { | ||
| match bytes_opt { | ||
| Some(bytes) => backend.put_sender_key(name, bytes).await?, | ||
| None => backend.delete_sender_key(name).await?, | ||
| } | ||
| } | ||
|
|
||
| // 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(); | ||
| // Phase 3: all writes succeeded — remove only the flushed keys from dirty sets. | ||
| // New mutations that occurred during Phase 2 remain in the dirty sets. | ||
| { | ||
| let mut state = self.sessions.lock().await; | ||
| for key in &session_dirty_keys { | ||
| state.dirty.remove(key); | ||
|
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.
In Useful? React with 👍 / 👎. |
||
| } | ||
| for key in &session_delete_keys { | ||
| state.deleted.remove(key); | ||
| } | ||
| } | ||
| { | ||
| let mut state = self.identities.lock().await; | ||
| for key in &identity_dirty_keys { | ||
| state.dirty.remove(key); | ||
| } | ||
| for key in &identity_delete_keys { | ||
| state.deleted.remove(key); | ||
| } | ||
| } | ||
| { | ||
| let mut state = self.sender_keys.lock().await; | ||
| for key in &sender_key_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.
Event::Disconnectedis now dispatched beforecleanup_connection_state()runs, so synchronous handlers can observe stale connection state (is_connected, transport/noise handles, caches) and make incorrect decisions (for example, skipping reconnect logic because the client still appears connected during the callback). This regression comes from removing the in-loop cleanup call without preserving the prior cleanup-before-dispatch ordering for unexpected disconnects.Useful? React with 👍 / 👎.