| index 02c4923..42ce9b9 100644 |
| --- a/core/src/snapshot.rs |
| +++ b/core/src/snapshot.rs |
| @@ -55,6 +55,20 @@ pub fn snapshot_bytes(doc: &Doc) -> Vec<u8> { |
| /// failure is closed by taking the snapshot from the replayed state |
| /// (the spec's D3), not here. |
| /// |
| +/// Nor can it catch `snapshot_bytes` being built from the wrong call. |
| +/// `ReadTxn::snapshot()` reads only `Store::blocks` — `pending` and |
| +/// `pending_ds` are separate `Store` fields it never touches — and |
| +/// `fragment_json` cannot render pending content either, so a candidate |
| +/// built by `encode_diff_v1` (which drops the pending queue; see this |
| +/// module's header) verifies as a perfect match against a document that |
| +/// is still holding data the candidate lost. This verifier is exactly |
| +/// as blind to that substitution as a render comparison would be. What |
| +/// actually guards it is `core/tests/snapshot.rs`'s |
| +/// `a_snapshot_with_a_parked_update_heals_once_its_dependency_arrives`, |
| +/// which parks an update, snapshots around it, and asserts the replay |
| +/// heals once the dependency arrives — a property only the correct call |
| +/// has. |
| +/// |
| /// guid/client_id are the caller's, never generated here — the clippy |
| /// disallowed-methods rule against drawing entropy from the CRDT library |
| /// applies to this throwaway document exactly as it does to a real one. |
| @@ -80,15 +94,20 @@ pub fn verify_snapshot(doc: &Doc, candidate: &[u8], guid: &str, client_id: u64) |
| } |
| // The state-vector/delete-set comparison is scoped to its own block |
| // so both read-only transactions (`a`, `b`) are dropped before |
| - // `fragment_json` runs below. `fragment_json` opens its own |
| - // transaction on the same `Doc`/`Store` internally |
| - // (`doc.transact()`) — holding `a` alive across that call would be |
| - // a second read acquisition on a store the first is still reading, |
| - // and `yrs`'s underlying `async_lock::RwLock` blocks that |
| - // read-while-reading-blocking case rather than permitting it |
| - // reentrantly, deadlocking the thread. Dropping `a`/`b` first |
| - // avoids relying on reentrant-read behaviour this lock doesn't |
| - // provide. |
| + // `fragment_json` runs below. The hazard is not two overlapping |
| + // reads — yrs's own docs say concurrent readers are fine, and |
| + // that's not what this is anyway. `fragment_json` calls `root(doc)` |
| + // (`get_or_insert_xml_fragment`) BEFORE it opens its own read |
| + // transaction, and that helper goes through `doc.transact_mut()` — |
| + // a WRITE acquisition — internally. Calling `fragment_json(doc)` |
| + // while `a` (a read guard from `doc.transact()`) is still alive |
| + // means that write acquisition blocks on the outstanding reader on |
| + // this same thread, and since `a` is never going to be dropped |
| + // while the very call it's blocking on hasn't returned, the thread |
| + // deadlocks itself. Dropping `a`/`b` first, before calling |
| + // `fragment_json` on either document, is what avoids it — the rule |
| + // is "never hold a transaction guard across a call that opens one |
| + // of its own," not "never overlap two reads." |
| let same_state = { |
| let (a, b) = (doc.transact(), rebuilt.transact()); |
| let (a_snapshot, b_snapshot) = (a.snapshot(), b.snapshot()); |