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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions