jackwener/OpenCLI · error · CommandExecutionError

Zhihu answer comments contained a reply cycle at ${cursor}

Error message

Zhihu answer comments contained a reply cycle at ${cursor}

What it means

resolveDepths computes each child's depth by walking reply_comment_id links up to the root. If the walk revisits a node already on the current path (active set), the parent links form a cycle, which would loop forever. The library throws naming the cursor id where the cycle was detected.

Source

Thrown at clis/zhihu/answer-comments-helpers.js:204

        if (root.child_comment_count === 0) continue;
        const children = await fetchChildComments(page, id, repliesLimit);
        if (children.length === 0) {
            throw new CommandExecutionError(`Zhihu answer root comment ${id} advertised replies but returned none`);
        }
        repliesByRoot.set(id, children);
    }
    return repliesByRoot;
}
function resolveDepths(rootId, childrenById) {
    const depths = new Map();
    for (const childId of childrenById.keys()) {
        if (depths.has(childId)) continue;
        const chain = [];
        const active = new Set();
        let cursor = childId;
        let depth = 0;
        while (cursor !== rootId && !depths.has(cursor)) {
            if (active.has(cursor)) throw new CommandExecutionError(`Zhihu answer comments contained a reply cycle at ${cursor}`);
            active.add(cursor);
            chain.push(cursor);
            const node = childrenById.get(cursor);
            if (!node) throw new CommandExecutionError(`Zhihu answer comments referenced missing parent ${cursor}`);
            cursor = node.descriptor.parentId;
        }
        if (cursor !== rootId) depth = depths.get(cursor);
        for (let index = chain.length - 1; index >= 0; index -= 1) depths.set(chain[index], ++depth);
    }
    return depths;
}
function toRow(comment, descriptor, ranks, context) {
    return {
        rank: ranks.rank,
        comment_rank: ranks.commentRank,
        reply_rank: ranks.replyRank,
        depth: ranks.depth,
        id: descriptor.id,

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Dump the childrenById map for the failing thread and trace reply_comment_id links to find the cycle members.
  2. Check whether any middleware rewrote ids (anonymizers/proxies) and fix the rewriting so ids stay unique and acyclic.
  3. Refetch the thread; genuine corruption is often transient cache content.
  4. Patch resolveDepths to cap walk length (e.g. at child count) if you must tolerate dirty data.

Example fix

// before: unbounded walk
while (cursor !== rootId && !depths.has(cursor)) { ... }
// after: bounded walk as a defense in depth
let hops = 0;
while (cursor !== rootId && !depths.has(cursor)) {
  if (hops++ > childrenById.size) throw new CommandExecutionError(`reply chain too long at ${cursor}`);
  ...
}
Defensive patterns

Strategy: validation

Validate before calling

// detect cycles in the parent-link graph before calling the library
function hasParentCycle(children) {
  for (const c of children) {
    const seen = new Set([String(c.id)]);
    let cur = c.reply_comment_id ? String(c.reply_comment_id) : null;
    while (cur && cur !== String(c.reply_root_comment_id)) {
      if (seen.has(cur)) return String(c.id);
      seen.add(cur);
      const node = children.find(x => String(x.id) === cur);
      cur = node?.reply_comment_id ? String(node.reply_comment_id) : null;
    }
  }
  return null;
}

Try / catch

try {
  const tree = await buildCommentTree(answerId);
} catch (err) {
  if (String(err.message).includes('reply cycle')) {
    console.error('Parent links are cyclic — payload corrupted; aborting this thread and refetching');
    return buildCommentTree(answerId);
  }
  throw err;
}

Prevention

When it happens

Trigger: Two or more child comments point at each other (or at themselves) via reply_comment_id, e.g. A.parent=B and B.parent=A, while neither is the rootId nor already resolved in depths.

Common situations: Corrupted or adversarially crafted API payloads, cache poisoning serving mutated rows, or manual post-processing of rows (e.g. rewriting ids for anonymization) that accidentally reused a parent id. Extremely rare with genuine Zhihu data.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/83713c5348e13179. Report an issue: GitHub.