Skip to content

fix: reject malformed movable list move/set ops on import instead of aborting - #1125

Merged
zxch3n merged 11 commits into
mainfrom
fix/movable-list-invalid-move-input
Sep 28, 2026
Merged

zxch3n merged 11 commits into
mainfrom
fix/movable-list-invalid-move-input

Conversation

@zxch3n

@zxch3n zxch3n commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Summary

Importing a malformed movable-list Move/Set op (found by Codex while reviewing #1119, also on main) panicked while the doc held its locks, poisoned them, and aborted the process. This PR makes those imports return Err with nothing applied, or, for moves/sets of deleted elements, applies them with the same result on every import path.

The adversarial review of the first version (NOT READY) found four blocking problems. All four are fixed here, and two of them were also bugs on main:

  • B1: honest imports were rejected on docs loaded from a snapshot. Root cause on main: decode_block_range read a version varint that the block encoding does not have, so every field was read one position off. As a result, get_change_with_lamport_lte / JS getChangeAtLamport returned None or the wrong change after a snapshot load, also on main (on a snapshot-loaded multi-block peer, 42 of 669 sampled lamports returned None and 625 the wrong change; 0 after the fix). The PR's element lookup used the same path.

    • Fixed the decoder.
    • A lookup miss now means "not in the stored history"; only on a shallow doc does it fall back to the shallow-root state.
  • B2: abort after a rolled-back import. Rollback could leave an older cached KV block right before the next insert ("counter should be continuous"). ChangeStore::rollback_import now evicts the flushed blocks of the peers it touched.

  • B3: validation could be bypassed. The import preflight skipped the ops of changes whose deps arrive in the same import. That affected [map-only change, forged op depending on it]:

    • no rollback scope and no validation;
    • the original panics were reachable again;
    • a cross-container move was accepted silently, putting one element in two lists.

    The preflight now inspects every new change's ops.

  • B4: positions ≥ 2^30 still panicked inside the diff tracker. InnerListOp::check_positions now rejects them when binary and JSON ops are decoded, for List, MovableList and Text ops (all were affected on main). Block decoding goes through the same decode_op, so this is a hard sequence-length limit (1,073,741,822 items) that also applies when a doc reads its own stored blocks or a snapshot. I kept it there on purpose: snapshot blocks are external input too, a sequence that long could not be imported by any peer before this PR either (the tracker panics), and an import-only check would need a trust flag threaded through block decoding. Documented in the context file and the changeset.

What the import does now

Rejected with Err, doc unchanged:

  • a Move/Set whose elem_id is not an Insert of the same list in the op's causal history (validated for every Move/Set inserted inside the import's rollback scope: direct changes, unlocked pending changes, whole import_batch);
  • out-of-bounds moves that survive the import (MovableListState::validate_diff);
  • positions past the tracker's placeholder span.

Applied with CRDT semantics: move/set of a deleted element. It is equivalent to a legitimate concurrent move/set (a forger can declare deps from before the delete), and Checkout-mode paths already applied it. The linear fast path now falls back to a Checkout-mode diff when a delta targets an element missing from the state. import, import_batch, snapshot + import, detached + attach and full replay agree (tested). Honest imports never take the fallback.

Performance

Round 1 measured +30–35% for many small movable-list imports and +10–18% for large ones. Round 2 still found large imports at +6–11%. Profiling showed three causes:

  1. The rollback record held an Arc of the peer's last change block, so every append copied that block's changes (copy-on-write). main already pays this for List/Tree imports.
  2. The validator re-read the imported range from the change store.
  3. For each Move/Set, the validator did a lamport lookup, a binary search through the change's ops, and a version-vector clone-and-insert (AppDag::get_vv). It was about 6.3% of a 120k-op import.

Fixes:

  • The rollback records a block's shape and truncates back to it.
  • insert_new_change records the Move/Set refs.
  • Each element id is resolved once per validation pass, since the result doesn't depend on the op.
  • The causal check reads the cached start version of the op's DAG node.

After these, the validator is about 2% of a large import.

Release builds, main 6e294c87 vs this PR (ca3cfeb4, same code as the head), alternating order each round, 7 rounds, import time only.

The reviewer's probe (r1125_perf, load 6.2–6.8):

workload main med ms PR med ms Δ med Δ min
small-ml (4000 small movable-list updates) 38.3 39.2 +2.4% +2.9%
small-ml-from-snapshot 41.4 43.0 +4.1% +4.4%
small-list 29.0 19.9 −31.5% −31.2%
small-tree 20.2 14.4 −28.6% −29.6%
big-ml-empty (120k ops, ~70% move/set) 180.3 187.1 +3.8% +3.5%
big-ml-linear 329.5 329.6 +0.0% +0.5%
big-ml-snapshot-nonempty 340.6 333.4 −2.1% +0.8%
big-ml-batch-snapshot-plus-update 223.4 228.8 +2.4% +2.9%
big-text-empty 138.4 136.3 −1.5% −0.6%

My probe (ml_import_perf, load 4.5–6.8):

workload main med ms PR med ms Δ med Δ min
small-ml 22.7 23.9 +5.2% +7.9%
small-ml-from-snapshot 24.6 26.0 +5.4% +6.0%
small-list 25.6 14.0 −45.5% −45.4%
small-text 9.7 9.7 +0.0% +0.8%
big-ml-empty 315.6 320.3 +1.5% +4.3%
big-ml-linear 351.3 359.6 +2.4% +1.1%
big-ml-snapshot-nonempty 366.7 373.9 +2.0% +1.6%
big-ml-batch-snapshot-plus-update 380.4 393.6 +3.5% +1.6%

Summary: movable-list imports are about 2–8% slower for many small imports and 0–4% slower for large ones. List/Tree imports are 30–45% faster. The earlier description's "+0.5%" for big-ml-empty came from one noisy series, and the reviewer could not reproduce it (they measured +11% at the time); these tables replace it.

Validation

  • (Numbers below are from round 1 unless noted; after merging main 6e294c87 and the validator change: cargo test -p loro -p loro-internal 52 binaries, 1,267 passed, 0 failed; pnpm check clean.)
  • Rust regressions:
    • crates/loro/tests/movable_list_invalid_ops.rs (16 tests: B1 snapshot-loaded move/set, B3 same-import dependency for 1 and 2 peers, attached/concurrent/detached, cross-container, B4 huge positions incl. List/Text, reversed delete at 0 still accepted, shallow-root cases);
    • change_store_test.rs (lamport lookup after snapshot load);
    • ChangeStore unit tests: KV-only lamport lookup, rollback evicting an older cached block (B2), rollback restoring an appended-to unflushed block byte-for-byte;
    • import_atomicity.rs: binary 2-peer B3 via import, single-blob import_batch, detached.
  • Each new test was checked to fail without its fix.
  • WASM: crates/loro-wasm/tests/movable_list_invalid_ops.test.ts (6 tests); the full package test script passes: 30 vitest files / 399 tests, tsc, Deno and Bun.
  • cargo test -p loro -p loro-internal: 49 test binaries, 0 failed. pnpm check: clean.
  • Fuzz: movable_list 10 min (632,713 runs), no crash; pnpm run-fuzz-corpus: exit 0.
  • Differential against main (random 3–5 peer histories with movable-list/nested/text/map edits, undo/redo, and syncs by:
    • plain import
    • snapshot import
    • import_batch (with and without a snapshot)
    • detached import + attach
    • JSON
    • checkout + import
    • restarts from full and shallow snapshots)
    • Result: 3 × 1500 seeds give identical per-step results, final values and events on both builds, with 0 panics.
  • Combined with fix: reject creating unknown-type containers instead of panicking #1142 head b550599d: merges cleanly; cargo test -p loro -p loro-internal 53 binaries, 0 failed.

Non-blocking review items

  • N1: an out-of-bounds move that a later op in the same import cancels is accepted, with the same result on every path. Documented.
  • N3 (event granularity) still reproduces. The reviewer's round-2 seed 1178 (1/1600) does detach → import 5 chunks → attach: main emits one Text Insert "abab" and the PR emits Insert "ab", Insert "ab". The content and resulting values are identical. My targeted shapes and my own event-hashing differential did not hit it, which is why the earlier text said it did not reproduce; that was wrong. It is a cosmetic difference in how the attach delta is split, and consumers that apply the deltas get the same text.
  • N5: the unreachable validation in the legacy import path was removed.

Known gaps (documented in context/movable-list-op-validation.md)

  • A Move whose from does not point at its element is accepted (the result is consistent across paths).
  • A detached import of an op that only state validation rejects still panics on attach() (all container types, predates this PR).
  • Ops inside a FastSnapshot are not validated one by one.

Filed separately (pre-existing on main, not fixed here):

🤖 Generated with Claude Code

…aborting

A movable-list Move/Set whose elem_id pointed at a deleted, unknown, or
out-of-history element, or a move past the end of the list, panicked while
the doc held its locks. The lock was poisoned and the process aborted.

- Validate Move/Set element references after oplog insertion, inside the
  import rollback scope. Covers attached, detached, legacy, and
  import_batch paths, and shallow roots.
- Bounds-check movable-list diffs in MovableListState::validate_diff, and
  enable import rollback for MovableList ops.
- Apply a move/set of a deleted element with concurrent-op semantics on
  every path. The linear fast path falls back to a Checkout-mode diff.

Rationale and remaining gaps: context/movable-list-op-validation.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3244.12 KB
  • Gzipped size: 1077.22 KB
  • Brotli size: 754.35 KB

zxch3n and others added 5 commits September 28, 2026 17:37
- decode_block_range read a version varint that the block encoding does
  not have, shifting every field. KV-only blocks were misread, so
  get_change_with_lamport_lte / getChangeAtLamport returned wrong
  results after a snapshot load (also on main), and the element
  validator rejected honest imports. idlp_to_id None now only falls back
  to the shallow-root state for trimmed history on shallow docs.
- ChangeStore::rollback_import evicts flushed blocks of touched peers,
  so a block cached during the scope cannot sit before the next insert
  with a counter gap ("counter should be continuous" abort).
- The import preflight inspects the ops of every new change, including
  ones whose deps arrive in the same import, so validation and rollback
  can no longer be bypassed.
- Sequence op positions past the diff tracker's placeholder span are
  rejected when decoding binary and JSON ops (all sequence containers).
- Drop the unreachable validation in the legacy import path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- ChangeStoreRollback kept an Arc of every pre-scope block an import
  appended to, so the append copied the block's changes (copy-on-write)
  on every import under a rollback scope. It now keeps the block's shape
  (change count, last change's op count and last op) and truncates back;
  flushed blocks are simply evicted and reload from KV. This also speeds
  up List/Tree imports that already ran under a rollback scope on main.
- insert_new_change records movable-list Move/Set element refs in the
  open rollback scope; the validator checks that list instead of
  re-reading the imported range from the change store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The validator looked the element's change up by lamport and then looked
it up again by id to read the op; read the op from the first result.
The import rollback also kept a second copy of the pre-import version
vector; share the change store's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mport lookups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Addressed the adversarial review (NOT READY) in a45d957d..bc9dc955, merged with main 1eb14b3b:

  • B1: fixed the root cause. decode_block_range read a nonexistent version varint, which also broke getChangeAtLamport after snapshot loads on main. A lookup miss now falls back to the shallow-root state only on shallow docs.
  • B2: ChangeStore::rollback_import evicts flushed blocks of the peers it touched, so a block cached during the scope can no longer precede the next insert.
  • B3: the preflight inspects the ops of every new change, including ones whose deps arrive in the same import. Covered for attached, concurrent, detached and single-blob import_batch, with 1 and 2 peers, plus cross-container.
  • B4: positions ≥ u32::MAX/4 - 1 are rejected when decoding binary and JSON ops, for List, MovableList and Text.
  • Perf: the rollback record no longer copies the peer's last change block on every import. Small movable-list imports went from +30–50% to about +5–9%, large ones are within noise, and small List imports are about 45% faster than main.
  • N5: the unreachable legacy-path validation was removed. N3 doesn't reproduce on the current head.

Tables, the differential runs (3×1500 seeds identical to main, including events) and the #1142 combination are in the updated description. The pre-existing main bugs from the report are #1155 and #1156.

🤖 Generated with Claude Code

zxch3n and others added 2 commits September 28, 2026 19:11
What an element id resolves to (insert op, container) does not depend on
the op that targets it, so cache it per pass. The causal check reads the
cached start version of the op's DAG node instead of cloning a version
vector and inserting into it for every op.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n and others added 2 commits September 28, 2026 19:21
…geset and context

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

zxch3n commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Round-2 follow-ups, pushed through d6888948 (merged main 6e294c87):

  1. Perf: profiled the 120k-op import. The validator was about 6.3% of it: a lamport lookup plus an op search per Move/Set, and a version-vector clone-and-insert (AppDag::get_vv) per op. Each element id is now resolved once per validation pass, and the causal check reads the cached start version of the op's DAG node, so the validator is about 2% of the import. With your r1125_perf probe (alternating, 7 rounds, load 6.2–6.8), comparing median / min:

    • big-ml-empty: +3.8% / +3.5% (was +11%)
    • big-ml-linear: +0.0% / +0.5%
    • snapshot-nonempty: −2.1% / +0.8%
    • batch: +2.4% / +2.9%
    • small-ml: +2.4% / +2.9%
    • small-list and small-tree: about −30%

    My own probe gives 0–4% for large and 5–8% for small movable-list imports. The description and changeset now state these numbers; the old "+0.5%" claim is gone.

  2. Changeset states the main bug: 42 undefined + 625 wrong of 669 getChangeAtLamport samples after a snapshot load, now 0.

  3. N3 is recorded as reproducible (seed 1178, cosmetic delta split), replacing "does not reproduce".

  4. 2^30 bound: kept at decode and documented as a hard sequence-length limit that also covers stored blocks and snapshots (reasons in the description and the context file).

  5. Issues: Importing a Text insert past the end of the text panics (richtext_state.rs:1653) #1160 (Text insert past the end panics) and Honest import panics after an import rejected by state validation was rolled back (arena values) #1161 (honest import panics after a state-validation rollback; reproduces with the harness at seed 120 on main in a debug build; I couldn't reduce it to a standalone test, and the root-cause hypothesis is in the issue).

cargo test -p loro -p loro-internal: 52 binaries, 1,267 passed, 0 failed. pnpm check: clean.

🤖 Generated with Claude Code

@zxch3n
zxch3n merged commit 5c7552d into main Sep 28, 2026
1 check passed
zxch3n added a commit that referenced this pull request Sep 28, 2026
…ontainer

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
zxch3n added a commit that referenced this pull request Sep 29, 2026
…) (#1168)

* test(loro.js): add a Rust differential suite for MovableList

Drive the Rust WASM build and loro.js with the same random multi-peer
edits, crossing engines on every sync, and require equal values, versions,
per-element metadata and events. Profiles that diverge on the current
MovableList model are recorded as known divergences. Design and findings:
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): compare event effects and replicate unordered reverts

Event deltas that differ only in shape are checked by effect; reverts whose
op order Rust leaves to hash order are checked by value and replicated from
Rust; peers that both engines reject are retired; peer IDs are never reused;
undo stays unchained; the minimizer keeps those rules. See
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): join shallow snapshots through Rust and keep replayed undo unchained

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): accept no-op sequence events and replicate reverts whose diff shape differs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): skip shallow editors and start range exports at the shallow root

Both follow Rust issues listed in context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): measure revert container depths at the revert target

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(loro.js): run the MovableList differential suite on the shared Rust reference

Load Rust through tests/support/rust-reference.ts like the other differential
suites, so it runs in `pnpm test` (and in CI's test-loro-js) and skips without
a WASM build. Drop the separate workflow and scripts. insert/delete now
converges on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): model MovableList like Rust (positions and LWW elements)

MovableList keeps a Fugue sequence of list items and elements whose
position and value are last-writer-wins registers, as Rust does. Moves of
concurrently deleted elements, deletes after moves, snapshots (list item,
element and last-set IDs, dead items), metadata getters, version
transitions, revertTo/applyDiff (Rust's apply_delta), undo and events now
match Rust. Snapshot-hydrated documents replay history before concurrent
imports, and a few shallow-snapshot incompatibilities are fixed. The
differential suite passes every profile. Design and remaining gaps:
context/loro-js-movable-list.md.

Rebuilt on current main: MovableList joins #1126's per-container snapshot
completion (its snapshot state names Rust IDs), applyDiff/revertTo/undo use
main's port of Rust #1138's apply_delta on the new model, and undo
transforms only over changes the UndoManager did not make.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip final syncs with a peer that left the run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): keep pending changes and follow Rust's shallow import rules

Decoding deferred snapshot history dropped pending changes. Shallow imports
now check the unknown part of each change like is_before_shallow_root, and an
import that only adds pending changes creates their root containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): pin diff() of a MovableList child created in the range

main's diff() reports such a child by its ops, including map keys set and
deleted again, as Rust does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): resolve concurrent MovableList op indices through a tracker

A causal view per concurrent op made importing concurrent moves O(n^2).
Like Rust's Tracker::checkout, keep a tracker version and a per-position
delta metric that moves incrementally between op versions; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(loro.js): describe the merged MovableList fixes in the changeset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): make import and importBatch atomic

A failed import no longer leaves history, pending changes, version,
containers, state or events half applied. In-place history changes are
journaled and undone, state is rebuilt only when it had changed, events are
emitted after success.
Details: context/import-batch-atomicity.md ("loro.js").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): reject MovableList moves and sets of elements outside their history

Port of #1125; see context/loro-js-movable-list.md (Validation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): never walk a container's fields when comparing event values

A MovableList replaced by a plain value made the comparison enumerate the
container and hit its guarded element sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fuzz MovableList in the multi-peer differential CI run

The Rust-model MovableList passes seeds 0-599 of the #1141 fuzz with all
oracles in lockstep with Rust; main failed 144 of seeds 0-299.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loro.js): apply MovableList diffs deletes first, like Rust's apply_delta

Without moved children the delta was walked in order, which gave the ops
other IDs than Rust's and later deletes by ID missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): skip map entries that only delete keys when comparing diff()

A child created in the range whose keys were all set and deleted again is
listed by Rust and omitted by loro.js, also on main and for any parent; see
context/loro-js-movable-list.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(loro.js): record snapshot completion on import and its cost

The performance context still described main's MovableList snapshot special
case and #1163 as open. Measurements are in context/loro-js-performance.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): check MovableList transition inserts in one tree walk

canTransition walked the position tree once per insert op, which made
checkout, diff and revertTo O(insert ops * positions). Collect the runs and
check them in one containsIdRuns call, as the List branch does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(loro.js): fix the suite's changeset and ignore empty diff() deltas

The changeset named a test:differential script that does not exist. Rust's
diff() can list a sequence with an empty delta where loro.js omits it
(context/loro-js-movable-list.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(loro.js): journal snapshot-sequence writes instead of copying per import

Each import copied the whole snapshot-sequence table for rollback, so its cost
grew with the number of snapshot-hydrated containers. Record each write in the
import journal instead (context/import-batch-atomicity.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant