I hit a reproducible panic in loro-internal 1.13.7 (and only 1.13.7 — 1.13.6 is green) when a movable list of containers takes a delete on one peer concurrently with an edit-then-move of the same element on another peer, and the edit and the move arrive as two separate update batches. Importing the second batch panics:
thread 'main' panicked at loro-internal-1.13.7/src/state/movable_list_state.rs:1210:38:
called `Option::unwrap()` on a `None` value
thread 'main' panicked at loro-internal-1.13.7/src/sync.rs:34:31:
poisoned LoroMutex
The second panic happens during unwinding (the doc mutex is poisoned by the first), which turns the panic into a process abort rather than a catchable error — that made it a bit alarming to track down in our test suite.
It looks like a 1.13.7 regression rather than a bug in movable_list_state.rs itself: that file is byte-identical between the 1.13.6 and 1.13.7 tags. Bisecting main points at #974 ("fix: harden diff calc for shallow text histories", 2026-07-07). My best guess at the mechanism — happy to be wrong — is that the reworked diff calc now reaches the create-new-element arm of apply_diff_and_convert with value_id: None, feeding the pre-existing unwrap() at line 1210. I haven't fully confirmed that part.
One thing that narrows it: if peer B's edit and move are exported as a single combined batch instead of two, there's no panic. The bug needs them to arrive as separate imports.
Minimal repro (depends only on the public loro API):
[dependencies]
loro = "=1.13.7"
use loro::{ExportMode, LoroDoc, LoroMap, LoroValue, ValueOrContainer};
// Flip to `true` to export both commits as one batch — then it does NOT panic.
const SINGLE_BATCH: bool = false;
fn index_of(d: &LoroDoc, id: &str) -> Option<usize> {
let l = d.get_movable_list("list");
(0..l.len()).find(|&i| {
matches!(l.get(i), Some(ValueOrContainer::Container(loro::Container::Map(m)))
if matches!(m.get("id"), Some(ValueOrContainer::Value(LoroValue::String(s))) if &*s == id))
})
}
fn map_at(d: &LoroDoc, idx: usize) -> LoroMap {
match d.get_movable_list("list").get(idx) {
Some(ValueOrContainer::Container(loro::Container::Map(m))) => m,
_ => panic!("expected map container"),
}
}
fn main() {
// Base doc: a movable list of two map containers, tagged "a" and "c".
let base = LoroDoc::new();
base.set_peer_id(1).unwrap();
let list = base.get_movable_list("list");
for tag in ["a", "c"] {
let m = list.insert_container(list.len(), LoroMap::new()).unwrap();
m.insert("id", tag).unwrap();
}
base.commit();
let snap = base.export(ExportMode::Snapshot).unwrap();
let mk = |peer: u64| {
let d = LoroDoc::new();
d.import(&snap).unwrap();
d.set_peer_id(peer).unwrap();
d.commit();
d
};
let pa = mk(0xA0);
let pb = mk(0xA1);
// Peer A: delete "c".
pa.get_movable_list("list").delete(index_of(&pa, "c").unwrap(), 1).unwrap();
pa.commit();
// Peer B, commit 1: edit c's map.
let v0 = pb.oplog_vv();
map_at(&pb, index_of(&pb, "c").unwrap()).insert("contents", "zombie").unwrap();
pb.commit();
let b_edit = pb.export(ExportMode::updates(&v0)).unwrap();
// Peer B, commit 2: move c to the front.
let v1 = pb.oplog_vv();
pb.get_movable_list("list").mov(index_of(&pb, "c").unwrap(), 0).unwrap();
pb.commit();
let b_move = pb.export(ExportMode::updates(&v1)).unwrap();
if SINGLE_BATCH {
let both = pb.export(ExportMode::updates(&v0)).unwrap();
eprintln!("import combined (edit c + move c)");
pa.import(&both).unwrap();
} else {
eprintln!("import batch 1 (edit c)");
pa.import(&b_edit).unwrap();
eprintln!("import batch 2 (move c)");
pa.import(&b_move).unwrap(); // <-- panics here on 1.13.7
}
eprintln!("no panic — list len {}", pa.get_movable_list("list").len());
}
A few directions that might make sense, though you'll know best:
- Tolerate
value_id: None in the create-new-element arm of apply_diff_and_convert rather than unwrapping.
- Return a
LoroError on that path instead of panicking, so a malformed/unexpected diff is recoverable.
- Independently of the root cause, the poisoned-mutex second panic escalating a recoverable error into a process abort seems worth hardening on its own.
Happy to help however's useful — test against a fix, open a PR, or provide more repros. For now we've pinned to =1.13.6 (loro-internal too, since the 1.13.6 facade can still pull loro-internal 1.13.7).
I hit a reproducible panic in
loro-internal1.13.7 (and only 1.13.7 — 1.13.6 is green) when a movable list of containers takes a delete on one peer concurrently with an edit-then-move of the same element on another peer, and the edit and the move arrive as two separate update batches. Importing the second batch panics:The second panic happens during unwinding (the doc mutex is poisoned by the first), which turns the panic into a process abort rather than a catchable error — that made it a bit alarming to track down in our test suite.
It looks like a 1.13.7 regression rather than a bug in
movable_list_state.rsitself: that file is byte-identical between the 1.13.6 and 1.13.7 tags. Bisectingmainpoints at #974 ("fix: harden diff calc for shallow text histories", 2026-07-07). My best guess at the mechanism — happy to be wrong — is that the reworked diff calc now reaches the create-new-element arm ofapply_diff_and_convertwithvalue_id: None, feeding the pre-existingunwrap()at line 1210. I haven't fully confirmed that part.One thing that narrows it: if peer B's edit and move are exported as a single combined batch instead of two, there's no panic. The bug needs them to arrive as separate imports.
Minimal repro (depends only on the public
loroAPI):A few directions that might make sense, though you'll know best:
value_id: Nonein the create-new-element arm ofapply_diff_and_convertrather than unwrapping.LoroErroron that path instead of panicking, so a malformed/unexpected diff is recoverable.Happy to help however's useful — test against a fix, open a PR, or provide more repros. For now we've pinned to
=1.13.6(loro-internaltoo, since the 1.13.6 facade can still pullloro-internal1.13.7).