Enforce single-owner contract on InMemoryPersister#1534
Enforce single-owner contract on InMemoryPersister#1534DanGould wants to merge 1 commit intopayjoin:masterfrom
Conversation
PR payjoin#1528 review feedback flagged that `Arc<RwLock<InnerStorage>>` inside `Clone`-able `InMemoryPersister` invites concurrent-write footgun: cloning the persister and sharing write access across actors is a logic bug against the SessionPersister contract. Drop `#[derive(Clone)]` and collapse internals to `Mutex<...>`. Callers that genuinely need sharing must opt in via `Arc<InMemoryPersister<V>>` and own the locking discipline. Same change applies to `InMemoryAsyncPersister` (still cfg(test)).
Coverage Report for CI Build 25483072556Coverage decreased (-0.01%) to 85.156%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
| @@ -838,40 +835,33 @@ where | |||
| type SessionEvent = V; | |||
|
|
|||
| fn save_event(&self, event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { | |||
There was a problem hiding this comment.
| fn save_event(&self, event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { | |
| fn save_event(&mut self, event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { |
maybe it makes sense to change the trait definition... @arminsabouri ? thoughts?
There was a problem hiding this comment.
Tested fn save(&mut self, …) on JsonReceiverSessionPersister with clankers. It fails to compile:
E0596: cannot borrow data in an Arc as mutable, both in the uniffi::export(with_foreign) macro
expansion and CallbackPersisterAdapter::save_event. Arc<dyn Trait> only implements
Deref, not DerefMut — not uniffi-specific.
Workarounds (split native vs FFI traits , or Arc<Mutex<dyn Trait>> callbacks) just
relocate the lock rather than remove it. Curious what @arminsabouri thinks — for this
PR I'd like to land the footgun fix and treat trait shape as its own discussion.
Follow-up to #1528 addressing review feedback from @nothingmuch in
#1528 (comment).
more net deletes 🤗
Summary
The previous
InMemoryPersister(andInMemoryAsyncPersister) wasCloneand usedArc<RwLock<InnerStorage<V>>>internally. That shape invites the concurrent-write footgun nothingmuch flagged: a caller can clone the persister and share write access across actors, which is a logic bug against theSessionPersistercontract — sessions are conceptually single-actor.This PR makes the single-owner intent type-level enforced:
#[derive(Clone)]from bothInMemoryPersisterandInMemoryAsyncPersister. Callers that need shared access wrap inArc<InMemoryPersister<V>>.Arc<RwLock<InnerStorage<V>>>→Mutex<InnerStorage<V>>(sync usesstd::sync::Mutex, async usestokio::sync::Mutex). The innerArconly existed to makeClonecheap; withoutCloneit's dead weight.RwLocksemantics never mattered here — both reads and writes serialize through the lock.InnerStorage<V>::eventsfromArc<Vec<V>>to plainVec<V>. TheArc::make_mut+try_unwrappattern always fell through to deep clone in practice (≥2 Arc holders at load time), so the indirection added zero value.In-scope side effects (mechanical, no behavior change):
inner.read()/inner.write()→inner.lock()at everypub(crate)access site acrosspayjoin/src/core/receive/v2/{mod,session}.rs,payjoin/src/core/send/v2/session.rs, and persist test helpers.+ Clonetrait bound on thedo_v2_to_v2<R, S>test helper inintegration.rs— the function body only takes&persister, never clones.No callers in-tree clone an
InMemoryPersister(the only.clone()s inpayjoin-ffiare onArc<dyn JsonReceiverSessionPersister>— unrelated). Compatible with #1533, which already wrapsInMemoryPersister<String>in newtypeArc<Self>(single producer, sharing viaArc<Newtype>notArc<InMemoryPersister>) — same architectural intent.Disclosure: co-authored by claude-opus-4-7-1m