zed-industries/zed · error · RpcError
CircularNesting
CircularNesting
Error message
CircularNesting
What it means
ErrorCode::CircularNesting from move_channel (crates/collab/src/db/queries/channels.rs:930): the new parent's ancestor chain (ancestors_including_self) contains the channel being moved, i.e. the new parent is the channel itself or one of its descendants. Nesting a channel inside its own subtree would create a cycle in the materialized parent_path.
Source
Thrown at crates/collab/src/db/queries/channels.rs:930
channel_id: ChannelId,
new_parent_id: ChannelId,
admin_id: UserId,
) -> Result<(ChannelId, Vec<Channel>)> {
self.transaction(|tx| async move {
let channel = self.get_channel_internal(channel_id, &tx).await?;
self.check_user_is_channel_admin(&channel, admin_id, &tx)
.await?;
let new_parent = self.get_channel_internal(new_parent_id, &tx).await?;
if new_parent.root_id() != channel.root_id() {
Err(anyhow!(ErrorCode::WrongMoveTarget))?;
}
if new_parent
.ancestors_including_self()
.any(|id| id == channel.id)
{
Err(anyhow!(ErrorCode::CircularNesting))?;
}
if channel.visibility == ChannelVisibility::Public
&& new_parent.visibility != ChannelVisibility::Public
{
Err(anyhow!(ErrorCode::BadPublicNesting))?;
}
let root_id = channel.root_id();
let new_parent_path = new_parent.path();
let old_path = format!("{}{}/", channel.parent_path, channel.id);
let new_path = format!("{}{}/", new_parent_path, channel.id);
let new_order = max_order(&new_parent_path, &tx).await? + 1;
let mut model = channel.into_active_model();
model.parent_path = ActiveValue::Set(new_parent.path());
model.channel_order = ActiveValue::Set(new_order);
let channel = model.update(&*tx).await?;View on GitHub (pinned to bc538def45)
Solutions
- Client-side, reject any drop target whose ancestor chain includes the dragged channel before sending the RPC
- Re-fetch the channel tree after any successful move so subsequent moves validate against fresh parent paths
- To swap hierarchy levels, first move the child out of the subtree to a neutral sibling, then perform the second move
Example fix
// before
client.move_channel(channel_id, channel_id /*self*/, admin_id).await?;
// after
let ok = !new_parent
.ancestors_including_self()
.any(|id| id == channel.id);
assert!(ok, "circular move");
client.move_channel(channel_id, new_parent_id, admin_id).await?; Defensive patterns
Strategy: validation
Validate before calling
// Reject drops onto the channel itself or any descendant
let circular = new_parent
.ancestors_including_self()
.any(|id| id == channel.id);
if circular {
return Err(anyhow!("cannot move a channel into its own subtree"));
} Try / catch
match db.move_channel(channel_id, new_parent_id, admin_id).await {
Ok(v) => Ok(v),
Err(err) if err.to_string().contains("CircularNesting") => {
Err(anyhow!("move would create a cycle; pick a target outside the subtree"))
}
Err(err) => Err(err),
} Prevention
- Run the ancestors_including_self check client-side on every drop candidate
- Serialize UI moves: wait for one move to finish (tree refreshed) before starting the next
- To invert a parent/child pair, move the child to a neutral sibling first
When it happens
Trigger: move_channel where new_parent_id == channel_id, or new_parent is a direct child/descendant of the moved channel — classic when swapping a parent and child ('move A into B' where B is already inside A).
Common situations: Drag-and-drop races with stale tree state: the UI moves B under A, then a queued move puts A under B; two admins restructuring concurrently; client caching that still shows the pre-move hierarchy.
Related errors
- WrongMoveTarget
- BadPublicNesting
- can't delete channel while a call is in progress
- channel name can't be blank
- no such invitation
AI-assisted analysis of zed-industries/zed@bc538def45 (2026-08-16).
Data as JSON: /api/errors/c854b6b890ff0e02.
Report an issue: GitHub.