Skip to content

fix: buffer pending ops on mergeable children instead of panicking#1017

Merged
lodystage[bot] merged 4 commits into
mainfrom
fix/mergeable-pending-import-panic
Jun 15, 2026
Merged

fix: buffer pending ops on mergeable children instead of panicking#1017
lodystage[bot] merged 4 commits into
mainfrom
fix/mergeable-pending-import-panic

Conversation

@lodystage

@lodystage lodystage Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

Importing an incremental update whose op targets a mergeable child container (ensureMergeable*) panics with unreachable / Option::unwrap() on a None value when the importing doc hasn't yet received that container's creation (out-of-order / partial delivery):

panicked at crates/loro-internal/src/state/container_store/container_wrapper.rs:114:42

For regular (non-mergeable) containers loro correctly buffers such ops as pending; for mergeable containers it traps. Reported against loro-crdt 1.13.2.

Root cause

When a change can't yet apply to the DAG (missing deps → buffered as pending), import_changes_and_apply_delta_to_state_if_needed takes the !preflight.applies_to_dag branch and calls pending_root_containers_to_materialize, which collects every op target with is_root() and eagerly builds its state via ensure_container.

Mergeable containers reuse the ContainerID::Root namespace but are logical children: their cid encodes (parent, key, kind) and their parent can be any map — including a Normal (non-root) map created by an op that also hasn't arrived yet. Eagerly materializing one while its deps are pending leaves its depth chain unresolvable (arena.get_depth returns None, and the parent_resolver is only installed during snapshot/KV decode), so ContainerWrapper::new's get_depth(idx).unwrap() panics.

This is a missed spot: #1010 added the !id.is_mergeable() guard to the sibling ensure_root_container path but not to pending_root_containers_to_materialize.

This also explains why the naive standalone repro didn't trigger it: the mergeable parent must itself be a missing Normal map (root → Normal map → mergeable child); if the mergeable child sits directly under a root map, its depth (=1) resolves fine.

Fix

Skip mergeable cids in pending_root_containers_to_materialize, mirroring the existing ensure_root_container guard. Pending changes on mergeable children are now buffered and materialized correctly through the normal diff path once the creating change applies.

Tests

Added mergeable_container::pending regression test: root → Normal map → mergeable text, deliver only the edit commit — it must buffer as pending (no panic) and converge once the creation arrives. Verified it reproduces the exact panic before the fix and passes after. Full cargo test -p loro-internal --features counter is green.

🤖 Generated with Claude Code

Importing an incremental update whose op targets a mergeable child whose
creation (and/or its parent map) has not arrived yet panicked with
`unreachable`/`Option::unwrap()` in `ContainerWrapper::new` at
`arena.get_depth(idx).unwrap()`.

Root cause: `pending_root_containers_to_materialize` collects every op
target with `is_root()` and eagerly builds its state. Mergeable containers
reuse the `ContainerID::Root` namespace but are logical *children* whose
parent can be any (possibly not-yet-imported) map, so their depth chain is
unresolvable while their deps are still pending, tripping the unwrap.

Skip mergeable cids in that path, mirroring the `!is_mergeable()` guard
already present in `ensure_root_container` (added in #1010 but missed here).
Pending changes on mergeable children are now buffered and materialized
correctly through the normal diff path once the creating change applies.

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

Copy link
Copy Markdown
Contributor

WASM Size Report

  • Original size: 3031.66 KB
  • Gzipped size: 1000.24 KB
  • Brotli size: 702.16 KB

zxch3n and others added 3 commits June 15, 2026 16:38
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review feedback:
- Capture the creation-only payload before the edit exists, so the second
  import delivers strictly the missing dependency. Previously it was exported
  from `Default::default()` after the edit, re-including the edit, so the
  convergence assert could pass even if the pending edit was dropped.
- Assert `import(edit_only).pending.is_some()` to confirm the edit is actually
  retained as pending, not silently discarded.
- Drop the unused `HandlerTrait` import (clippy -Dwarnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lodystage lodystage Bot merged commit 8d258cb into main Jun 15, 2026
1 check passed
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