Repository navigation
fix: reject malformed movable list move/set ops on import instead of aborting - #1125
Conversation
…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>
WASM Size Report
|
- 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>
|
Addressed the adversarial review (NOT READY) in
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 |
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>
…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>
|
Round-2 follow-ups, pushed through
🤖 Generated with Claude Code |
…ontainer Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…) (#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>
Summary
Importing a malformed movable-list
Move/Setop (found by Codex while reviewing #1119, also onmain) panicked while the doc held its locks, poisoned them, and aborted the process. This PR makes those imports returnErrwith 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_rangeread 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/ JSgetChangeAtLamportreturnedNoneor the wrong change after a snapshot load, also onmain(on a snapshot-loaded multi-block peer, 42 of 669 sampled lamports returnedNoneand 625 the wrong change; 0 after the fix). The PR's element lookup used the same path.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_importnow 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]:The preflight now inspects every new change's ops.
B4: positions ≥ 2^30 still panicked inside the diff tracker.
InnerListOp::check_positionsnow rejects them when binary and JSON ops are decoded, for List, MovableList and Text ops (all were affected onmain). Block decoding goes through the samedecode_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:Move/Setwhoseelem_idis not anInsertof 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, wholeimport_batch);MovableListState::validate_diff);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:
Arcof the peer's last change block, so every append copied that block's changes (copy-on-write).mainalready pays this for List/Tree imports.AppDag::get_vv). It was about 6.3% of a 120k-op import.Fixes:
insert_new_changerecords the Move/Set refs.After these, the validator is about 2% of a large import.
Release builds, main
6e294c87vs 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):My probe (
ml_import_perf, load 4.5–6.8):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
6e294c87and the validator change:cargo test -p loro -p loro-internal52 binaries, 1,267 passed, 0 failed;pnpm checkclean.)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);ChangeStoreunit 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 viaimport, single-blobimport_batch, detached.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.movable_list10 min (632,713 runs), no crash;pnpm run-fuzz-corpus: exit 0.main(random 3–5 peer histories with movable-list/nested/text/map edits, undo/redo, and syncs by:import_batch(with and without a snapshot)b550599d: merges cleanly;cargo test -p loro -p loro-internal53 binaries, 0 failed.Non-blocking review items
Insert "abab"and the PR emitsInsert "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.Known gaps (documented in
context/movable-list-op-validation.md)Movewhosefromdoes not point at its element is accepted (the result is consistent across paths).attach()(all container types, predates this PR).FastSnapshotare not validated one by one.Filed separately (pre-existing on
main, not fixed here):updates_in_rangewith several spans of one peer panics.richtext_state.rs:1653).🤖 Generated with Claude Code