Skip to content

Movable list panics (unwrap on None at movable_list_state.rs:1210) when a delete and a concurrent edit+move import as separate update batches — 1.13.7 regression #1046

Description

@quantools

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:

  1. Tolerate value_id: None in the create-new-element arm of apply_diff_and_convert rather than unwrapping.
  2. Return a LoroError on that path instead of panicking, so a malformed/unexpected diff is recoverable.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions