Skip to content

is_deleted() aborts with "Parent is not registered" on deleted-node metadata in a snapshot-loaded doc #1158

Closed

Description

@zxch3n

is_deleted(), and every local edit, on the metadata of certain deleted tree nodes panics with InternalError: Parent is not registered (arena.rs:462) in a document loaded from a snapshot (or created by fork()). The panic happens while the state lock is held, so the lock is poisoned and the process aborts (sync.rs:34 while unwinding). Importing the same history as updates instead of a snapshot works (is_deleted() == true).

Reproduces on main (1eb14b3); found by the adversarial review of #1152 / #1153 (their dead-container harness aborts on seeds 5 and 116 of 150).

Repro (loro crate, debug or release)

use loro::{ContainerTrait, ExportMode, LoroDoc, TreeParentId};

#[test]
fn snapshot_meta_of_node_created_under_deleted_parent() {
    let a = LoroDoc::new();
    a.set_peer_id(1).unwrap();
    let tree = a.get_tree("tree");
    let parent = tree.create(TreeParentId::Root).unwrap();
    a.commit();
    let b = LoroDoc::new();
    b.set_peer_id(2).unwrap();
    b.import(&a.export(ExportMode::all_updates()).unwrap()).unwrap();
    // b creates a child under `parent` while a concurrently deletes `parent`
    let child = b.get_tree("tree").create(parent).unwrap();
    b.commit();
    tree.delete(parent).unwrap();
    a.commit();
    a.import(&b.export(ExportMode::all_updates()).unwrap()).unwrap();
    // later history, so that the child's Create sits in an older change block
    for i in 0..5000 {
        a.get_map("m").insert(&format!("k{}", i % 50), i).unwrap();
        a.commit();
    }

    let loaded = LoroDoc::new();
    loaded.import(&a.export(ExportMode::Snapshot).unwrap()).unwrap();
    let meta = loaded.get_tree("tree").get_meta(child).unwrap();
    meta.is_deleted(); // panics: InternalError: Parent is not registered (arena.rs:462) -> abort
    // meta.insert("k", 1) aborts the same way (Transaction::apply_local_op checks is_deleted first)
}

Without the 5000 filler commits, or when the child is created before the delete by the same peer, it returns true as expected.

Cause (from reading the code)

DocState::is_deleted walks SharedArena::get_parent. The meta container's parent link comes from one of:

  1. parsing the change that holds the node's Create op (register_container_and_parent_link, visit_created_children registers the meta with the tree as parent), or
  2. the lazy resolver installed by InnerStore::decode (inner_store.rs ~515), which reads the parent from the child's own KV entry in the snapshot state.

Here neither applies: the snapshot's change blocks are parsed lazily and the Create is in an old block nobody has parsed, and the meta of a node that was created inside an already-deleted subtree was never alive, so the state has no entry for it. get_parent then panics (arena.rs:462).

Possible fixes: let the resolver fall back to the container's creating op (a normal container ID is the ID of the op that created it; parsing that change registers the parent), taking care that the change store may already be locked when get_parent runs and that the op can be below a shallow root; or treat "no resolvable parent" as deleted in is_deleted instead of panicking. Both are larger than the PRs that found it, so they are not included there.

Since this is reachable from loro-crdt too (isDeleted() / edits on such a node's data), it traps the WASM instance.

🤖 Generated with Claude Code

Activity

  1. added 2 commits that reference this issue on Sep 28, 2026
    fcb23ac
    ce6498b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions