Repository navigation
fix: issue sweep (#1118, #1162, #1155, #1165, #1156, #1164, #1157, #1154, #1151; #1149 documented) - #1171
Merged
Conversation
…1118) Importing history from a client that shared a peer id trimmed the "known" part by version vector and applied the tail on top of different local history: it panicked in state apply (poisoning the doc, aborting on drop) or silently scrambled text. Compare the overlap with local history before anything is mutated and return UsedOpID; also reject changes that skip counters of their peer instead of tripping a DAG assertion. Rationale: context/import-peer-id-reuse.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dropping a subscriber's callback runs arbitrary code; when it owns another Subscription or an UndoManager on the same doc, their drop re-entered the set's mutex on the same thread and deadlocked. The unsubscribe closure, retain's dropped-subscriber sweep, and remove() now move the removed subscribers out and drop them after releasing the lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nge (#1155) export_blocks_in_range inserted every selected change into one temporary change store, which requires each peer's counters to be contiguous, so two non-contiguous spans of one peer panicked under the op log lock. Merge the ranges per peer and put the k-th range of every peer into the k-th store; the blocks of all stores are written one after another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A version addressed with op-level frontiers can hold a mark's StyleStart but not its StyleEnd. checkout keeps the lone start anchor, but the fast state decoder rejected it, and the first read of a fork or snapshot panicked under the doc locks. Decode it the way checkout keeps it (no style range until the end arrives), relax the debug-only pair checks accordingly, leave it alone in dead-style redaction, and only advance a shallow root past a StyleStart when the StyleEnd exists in the op log. Rationale: context/shallow-snapshot-style-redaction.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deletes written by WASM loro-crdt <= 1.16.3 can name the wrong characters in start_id. The ops are immutable and the affected readers (shallow-snapshot placeholders, cursors on deleted text) have nothing but start_id, so the history is not repaired in code. Document which paths stay correct and how to avoid the wrong ones, and pin that advice with a test on a forged legacy history (plus the still-divergent shallow root before the delete). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A root is visible when the state store has it. The writing peer creates it
with its first local op, but an import only did so when the root's net diff
was non-empty, so text inserted then deleted showed {"t": ""} live and {}
in a replay. After an attached import, and after a checkout to the latest
version, create the state of every non-mergeable root the diff calculator
touched.
Getting a root handle also makes it visible, by design; that half of the
issue's random workload is an API decision and is documented, not changed.
Rationale: context/root-container-visibility.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zxch3n
marked this pull request as ready for review
September 30, 2026 05:46
A failed import's arena rollback freed the indices of containers registered during the import, while DocState store entries, the dead-container cache and handlers on other threads still referred to them. The next container registered at a freed index inherited the old state. The rollback now keeps every index and drops only what the failed import may have told the arena (parent links of new normal containers, cached depths), plus values and text; every rollback path also clears the dead-container cache. Rationale: context/failed-import-arena-indices.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In the Linear and ImportGreaterUpdates diff modes the tree diff is the raw ops, and TreeState::apply_diff_and_convert labeled a move that brought a node out of a deleted subtree as a single Create. The nodes below it got no Create, so a tree rebuilt from events lost them. In Linear mode a move of an alive node under a deleted one also emitted no Delete, and in ImportGreaterUpdates mode Create ops skipped the cycle check that apply_diff applies. Raw Create/Move ops are now classified by whether the target is alive before and after the op, and a revived node is followed by a Create for each node of its subtree. Checkout-mode diffs are unchanged. Rationale and the local-event gap: context/tree-events.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
WASM Size Report
|
A batch that fails is rolled back: its ops are taken out of the transaction (uncommitted edits before it stay), undone from the state by a checkout through a temporary change under an import rollback scope, and removed from the DAG. No event is emitted. A stale tree diff naming a missing parent now returns an error instead of panicking. Rationale, reused op ids and the multi-thread fallback: context/apply-diff-atomicity.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ild (#1151) A map event that set a key to a container of an unknown type (created by a newer loro-crdt) failed to convert as a whole, so edits to known keys in the same event never reached subscribers. Leave out only the unknown child's entry. List events holding an unknown child are still left out whole, and delivering the unknown child itself needs a public representation (an API decision); see context/unknown-containers.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One PR for the remaining issue backlog, one commit per issue (each commit message carries its issue number and points at the context article with the rationale). It replaces #1170, which GitHub closed when its branch was renamed.
b2c6a4d9ae1a9bdfupdates_in_rangepanics with several spans of one peer11e8518ae148f0ecstart_ids from loro-crdt ≤ 1.16.3207c81f02ced4e6c4de63a99Createfor children of a revived node8c20916bapply_diffapplies part of a batch before erroringccdcee350862c8daEvery fix was first reproduced by a test that fails on
main. The details are in each section below.#1118: imports that reuse local op ids
Repro. Two docs share peer 7 and write different ops at the same ids. Importing one into the other trimmed the "known" part by version vector and applied the tail on top of the wrong history. That panicked in
RichtextState::apply_diffunder the doc locks (thenDropaborted on the poisoned mutex), or returnedOkwith scrambled text.Fix.
OpLog::check_and_trim_known_part_of_changes(oplog/known_history.rs) runs right after decode, before anything is mutated. When the import brings anything new, it compares the overlap of each imported change with local history:A mismatch returns the existing
LoroError::UsedOpID { id }. A change whose deps are present but which skips counters of its own peer returnsDecodeError; it used to hit a DAG assertion under the lock. Seecontext/import-peer-id-reuse.md.Validation.
crates/loro/tests/import_reused_peer_id.rs(9 tests: updates, snapshot,import_batch, JSON, drop insidecatch_unwind, legit overlapping re-imports) andcrates/loro-wasm/tests/import_reused_peer_id.test.ts. On main these abort, trap (unreachable) or scramble.#1162: dropping a Subscription deadlocks
Repro. The issue's two shapes: a callback owning another
Subscription, or owning anUndoManageron the same doc. Dropping the outer subscription dropped the callback while the subscriber set's mutex was held, and the inner drop took the same mutex again.Fix. Three sites now drop removed subscribers only after releasing the lock: the unsubscribe closure,
retain's sweep of subscribers dropped mid-emit, andremove().Validation.
crates/loro/tests/subscription_drop_reentrant.rs(4 tests, each with a 5 s deadlock timeout); all 4 time out on main.pnpm test-loompasses. No WASM test: a JS callback's captures are GC-managed, so it cannot own a RustSubscription.#1155:
updates_in_rangewith several spans of one peerRepro. Spans
1@0..2and1@5..7panicked with "counter should be continuous" under the op log lock.Fix. The ranges of each peer are merged, and the k-th range of every peer goes into the k-th temporary change store. Their blocks are written one after another, which the update decoder already reads. With one range per peer (the common case) there is still a single store.
Validation.
crates/loro/tests/export_updates_in_range.rscovers non-contiguous, overlapping and unordered spans across several peers, and many one-op spans.crates/loro-wasm/tests/export_updates_in_range.test.ts.#1165: snapshot of a version with a mark's start but not its end
Repro.
forkAtor a snapshot at an op-level version that holdsStyleStartbut notStyleEndpanicked on the first read ("unclosed style mark").Fix.
checkoutalready keeps such a lone start anchor, which styles nothing until its end arrives.StyleStarttoc + 1when that op exists. It used to fail withFrontiersNotFoundfor a doc whose history ends at the start.See
context/shallow-snapshot-style-redaction.md.Validation.
crates/loro/tests/snapshot_unclosed_style_mark.rs(fork, snapshot, re-export, shallow snapshot, then closing the mark by importing the rest) andcrates/loro-wasm/tests/unclosed_style_mark.test.ts.#1149: legacy text-delete
start_ids (not fixed in code)The bad ops are already in users' histories and are immutable. The readers that go wrong have nothing but
start_idto work with:Deriving the IDs from positions there would mean seeding the shallow tracker from the root state's per-character IDs, a large diff-calculation change for a narrow legacy case. Instead,
context/richtext-insert-positions.mdnow documents what stays correct and how to avoid the wrong paths:crates/loro/tests/legacy_text_delete_start_id.rsforges the legacy op through JSON and pins both sides:"x!"), which is the open part.No
Fixes: the issue should stay open, or be closed as documented, at the maintainers' call.#1156: empty root containers and replay
Repro. Text inserted and then deleted showed
{"t": ""}on the writer, but{}in a fresh replay of the same history.Fix. A root is visible when the state store has it. An import only created the root's state when its net diff was non-empty. Now, after an attached import, and after a checkout to the latest version (
attach, the end ofimport_batch), every non-mergeable root the diff calculator touched gets its state (materialize_touched_roots). It reuses the calculator's container set; there is no per-op scan.Remaining part (API decision, not changed). The issue's random workload also found mismatches where the history never touches the root. Bisecting showed they come from reads:
doc.get_movable_list("l").len()alone makes"l": []visible, by design (get_*creates the root's state, snapshots keep it, andhide_empty_root_containersis the opt-out). A value that depends on history only would need a change to that public behavior.Checking out an older version still keeps roots first touched later. See
context/root-container-visibility.md.Validation.
crates/loro/tests/root_visibility_replay.rs:Plus
crates/loro-wasm/tests/root_visibility_replay.test.ts.#1164: failed import and freed arena indices
Repro. The issue's deterministic test: after a failed import, a new root map inherited
"t": "z"from state held at an index the rollback had freed. Retrying the same bad update panicked with anInnerStoretype assertion.Fix. A failed import's arena rollback no longer frees indices.
DocStateentries, the dead-container cache and handlers on other threads are all keyed by them. What the rollback drops instead:Every rollback path also clears the dead-container cache. A failed import changes no container state (the whole batch is validated first), so the kept entries are safe. Roots it registered without state stay hidden. I checked that a container created only by the failed import reports
has_container == false.Known gaps (pre-existing, documented in
context/failed-import-arena-indices.md):set_parentjournal;Validation.
crates/loro/tests/failed_import_state_rollback.rs: the issue repro, containers created after a failure, retry rejected again, and aBarrier-based threaded stress test (failed 10/10 on main). Pluscrates/loro-wasm/tests/failed_import_state_rollback.test.ts, which revives 40 nodes because hash order differs on wasm32.#1157:
Createevents for children of a revived tree nodeRepro. The issue's two blobs:
3@1008ended up alive under revived0@1008with noCreate. A smaller shape: a linear delete ofp, then a move of its child out.Root cause. In
Linear/ImportGreaterUpdatesmode,TreeState::apply_diff_and_convertturned a move out of a deleted subtree into a singleCreate, and nothing below the node got one. The same code emitted nothing for a move into a deleted subtree, and skipped the cycle check for rawCreate.Fix. A new
apply_raw_move_and_convertclassifies each raw op by whether the node is alive before and after it:Move, or nothing if parent, index and position are unchanged;Delete;Create, then aCreatefor every node of its subtree, parents first;Checkoutmode is unchanged. Seecontext/tree-events.md.Validation.
crates/loro/tests/tree_revival_events.rsreplays events into a mirror and compares it with the state. It covers:On main the random test failed 15 of 100 seeds. Plus
crates/loro-wasm/tests/tree_revival_events.test.ts.#1154:
apply_diff/revert_toare all-or-nothingRepro. The issue's batch (text insert plus out-of-bounds list delete) returned
OutOfBoundwith the text still inserted. The same applies to a diff type mismatch, a missing container, and staledoc.diff(a, b)applied offa.Fix. Validating the whole batch up front would mean predicting every handler (tree and movable-list semantics, container recreation, id remapping), so the batch is rolled back instead:
A failed call emits nothing, so subscribers and
UndoManagersee nothing. If another thread edits the same transaction mid-batch, the old partial behavior is kept (and logged) so its edits aren't lost. Undo/redo keeps its existing best-effort behavior. Separately, a stale diff naming a missing tree parent now returns an error instead of hitting anunwrap. Seecontext/apply-diff-atomicity.md.Validation.
crates/loro/tests/apply_diff_atomicity.rs(16 tests). It includes a twin test: random stale diffs on two-peer histories, where the doc must match a twin that never made the call, through later edits that reuse the ids (10,000 seeds pass in release). I re-ran the suite against the pre-fix sources on this branch: 12 of 16 fail, including the issue repro. Pluscrates/loro-wasm/tests/apply_diff_atomicity.test.ts.#1151: events next to unknown containers
#1142 already fixed the title problem: a batch touching an unknown container no longer drops the events of known containers. The existing test is "still emits the events of known containers next to unknown ones".
What was still lost: a map event that sets a key to an unknown child failed to convert as a whole, so known keys updated in the same event (e.g.
m.titlenext tom.u) never reached subscribers.map_delta_to_jsnow leaves out only the unknown entry. JSON-form output already writes the child as its container id.Left open (API decision). The unknown child itself (the omitted map entry, or a list insert holding one, which is still dropped whole because leaving out one item would shift the indices of the rest) needs a public JS representation:
nullas intoJSON, the🦜:cid:...string, or a wrapper class. The issue itself defers that to a follow-up; it's written up incontext/unknown-containers.md.Fixesis used because the reported problem is solved.Validation.
crates/loro-wasm/tests/unknown_container_events.test.tscovers a root map and a nested map, throughdoc.subscribeand a container subscription. Both tests failed before this commit; the existing unknown-container tests still pass.Validation (head
0862c8da)cargo test --no-fail-fast -p loro -p loro-internal: 1381 passed, 0 failed (68 binaries).pnpm check: clean.pnpm test-loom: 9/9 pass.pnpm -C crates/loro-wasm build-dev(vitest, tsc, deno, bun): vitest 43 files / 459 passed (2 skipped, 2 todo); the CommonJS smoke, tsc, deno and bun tests all pass.movable-list"stay linear": ratio 24.46 against a limit of 24 at loadavg ≈ 24). The CIloro-jsjob passes.build,loro-js) is green on0862c8da.Perf (release, best of 7, three alternating main/PR rounds, loadavg 17–20 on a shared machine)
892ed30bEverything is within noise except two costs:
importwith a reused peer id panics and poisons the doc; dropping it can abort #1118): that whole overlap is now compared, and that is the shape where reused ids are caught.The subagent probes for #1164 (success path unchanged;
crates/examples/examples/failed_import_rollback_perf.rs) and #1157 (40k tree ops, within noise) showed no regression.crates/examples/examples/import_known_history_perf.rsis the import probe.Follow-ups (not in this PR)
get_*should stop making empty roots visible, and root visibility after checking out older versions.importwith a reused peer id panics and poisons the doc; dropping it can abort #1118:p@2with deps[]over a localp@0..=1) is still accepted;Dropstill panics on a poisonedLoroMutexafter any other panic inside the locks (import() panics when a remote update depends on ops folded into a shallow snapshot; still on main after #1064, and the panic poisons the doc mutex #1068).set_parentjournal for links set on older containers.loro-internalLoroDoc::txn()edits on other threads during an import.DocState::apply_diffcan still fail mid-loop without rollback if validation misses a case.crates/fuzz'sassert_value_eqonly warns on mismatch since refactor: improve loro fuzzing coverage and fix discovered bugs #953, so value checks in fuzz never fail (recorded incontext/CONTEXT-GAPS.md).revert_tocan restore the wrong tree sibling order: 1 of 3,000 random cases, also on main.Fixes #1118
Fixes #1162
Fixes #1155
Fixes #1165
Fixes #1156
Fixes #1164
Fixes #1157
Fixes #1154
Fixes #1151
🤖 Generated with Claude Code