Skip to content

fix(core): preserve duplicate object references in safeJsonStringify - #4407

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
ihubanov:fix/safe-json-stringify-dup-refs
May 25, 2026
Merged

fix(core): preserve duplicate object references in safeJsonStringify#4407
wenshao merged 2 commits into
QwenLM:mainfrom
ihubanov:fix/safe-json-stringify-dup-refs

Conversation

@ihubanov

@ihubanov ihubanov commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • What changed: safeJsonStringify now flags only true cycles. Duplicate references (the same object appearing in multiple unrelated positions in a graph) are preserved as full copies, matching native JSON.stringify on acyclic graphs.
  • Why it changed: The replacer used a single WeakSet of every object it had ever seen. Since JSON.stringify calls the replacer for every key in a DFS walk and the set was never trimmed when the walk unwound, the second sibling that pointed at the same object got replaced with [Circular]. Not a cycle, just a duplicate reference. Same false positive for repeated array elements and shared leaves on multiple branches.
  • Reviewer focus: packages/core/src/utils/safeJsonStringify.ts. The replacer's this is the parent of value, so on each call we trim the ancestor stack back to wherever the walk currently is, then check the remaining ancestors for membership. True cycles still produce [Circular]; duplicates serialize normally.

Validation

  • Commands run:
    cd packages/core && npx vitest run src/utils/safeJsonStringify.test.ts
    cd packages/core && npx vitest run \
      src/telemetry/loggers.test.ts \
      src/tools/tool-registry.test.ts \
      src/tools/mcp-tool.test.ts
    npx eslint packages/core/src/utils/safeJsonStringify.ts packages/core/src/utils/safeJsonStringify.test.ts
  • Expected result: 8 pre-existing helper tests pass; 5 new regression tests pass; the three downstream caller test files that consume the real implementation pass; lint clean.
  • Observed result:
    • safeJsonStringify.test.ts: 13/13 pass.
    • loggers.test.ts: 45/45 pass.
    • tool-registry.test.ts: 32/32 pass.
    • mcp-tool.test.ts: 53/53 pass.
    • Total: 143 tests across 4 files genuinely exercise the new ancestor-stack walk.
    • Lint clean.
  • Coverage caveat: qwen-logger.test.ts and message-bus.test.ts both vi.mock('../utils/safeJsonStringify.js', ...) with native JSON.stringify, so they do not exercise this change. Earlier draft listed them in the validation set; corrected here.
  • Quickest reviewer verification path: run the helper's test file. The three "should preserve" tests (sibling, array, subtree leaf) fail on main and pass after the change.
  • Evidence (before / after on the same inputs):
    safeJsonStringify({ a: shared, b: shared })
      before: {"a":{"name":"shared","n":1},"b":"[Circular]"}
      after:  {"a":{"name":"shared","n":1},"b":{"name":"shared","n":1}}
    
    safeJsonStringify([shared, shared, shared])
      before: [{"name":"shared","n":1},"[Circular]","[Circular]"]
      after:  [{"name":"shared","n":1},{"name":"shared","n":1},{"name":"shared","n":1}]
    
    safeJsonStringify({ left: { sub }, right: { sub } })
      before: {"left":{"sub":{"kind":"leaf"}},"right":{"sub":"[Circular]"}}
      after:  {"left":{"sub":{"kind":"leaf"}},"right":{"sub":{"kind":"leaf"}}}
    
    safeJsonStringify(realCycle)  // parent.self = parent
      before: {"name":"parent","self":"[Circular]"}
      after:  {"name":"parent","self":"[Circular]"}   (unchanged)
    

Scope / Risk

  • Main risk or tradeoff: The output bytes change for any graph that contains duplicate object references. Callers that were keying on the appearance of [Circular] as a "we already saw this object" signal would lose that signal. All current callers in the tree are telemetry / logging / error-message surfaces where the underlying data is the goal, not a "we saw it" marker, so impact is positive (more readable telemetry, fewer mystery [Circular]s in MCP error messages, fewer [Circular]s in tool-registry toString() output for shared param schemas).
  • Not covered / not validated: I did not run the full preflight on this branch (clean / build / typecheck-all / test:ci); ran the targeted vitest set above instead. Type signature and exported shape of safeJsonStringify are unchanged.
  • Breaking changes / migration notes: None at the API level. Same signature, same [Circular] token for true cycles, same space parameter behavior.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️ ⚠️
Docker ⚠️ ⚠️ ⚠️
Podman ⚠️ N/A N/A
Seatbelt ⚠️ N/A N/A

Testing matrix notes:

  • Vitest verified on Linux (Node 22). The change is pure JS with no platform-specific surface; behavior is fully covered by unit tests.

Linked Issues / Bugs

No linked issues.

The replacer kept a WeakSet of every object it had ever seen. JSON.stringify
calls the replacer for every key in a DFS walk, siblings included, and the
set was never trimmed when the walk unwound. So the second sibling that
pointed at the same object got replaced with [Circular]. Not a cycle, just
a duplicate reference. Same false positive for repeated array elements and
for any shared leaf that appears on more than one branch.

Track the current ancestor path instead. The replacer's `this` is the parent
of `value`, so on each call pop the stack back to wherever the walk currently
is, then check the remaining ancestors for membership. Only real cycles get
flagged.

Existing cycle tests still pass. Added five regression tests covering shared
siblings, repeated array elements, shared subtree leaves, indirect cycles,
and a mix of duplicate ref + real cycle in the same graph.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] PR description lists src/telemetry/file-exporters.test.ts as a downstream test file, but this file does not exist in the repo. Additionally, qwen-logger.test.ts:42 and message-bus.test.ts:27 mock safeJsonStringify with native JSON.stringify, so those 2 of 6 downstream files don't exercise the new ancestor-stack implementation. Consider updating the validation section to reflect which files actually test the new code.

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

// to `this` so the stack reflects only the current chain of ancestors.
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] DAG with shared refs can produce exponentially large output. The old WeakSet collapsed all revisited objects to [Circular] (O(n) output). The new algorithm fully expands every duplicate reference — correct per JSON.stringify semantics, but it removes the implicit output-size guard. A DAG with 50 levels where each node has 2 children referencing the same leaf produces ~2⁵⁰ copies of the leaf in the output, blocking the event loop until OOM.

This matters because safeJsonStringify is called on MCP tool params (mcp-tool.ts:452) and tool registry params (tool-registry.ts:48), which originate from external MCP servers (attacker-controlled input).

Consider adding a node-count cap as a safety valve:

let nodeCount = 0;
const MAX_NODES = 10_000;
// in replacer, before ancestors.push:
if (++nodeCount > MAX_NODES) return '[Truncated]';

— qwen-latest-series-invite-beta-v36 via Qwen Code /review

@ihubanov ihubanov May 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair concern, but I think this is the cost of correctness over the old behavior.

Native JSON.stringify already produces the 2^N output on the same DAG shape, so what the helper does now matches what callers would get if they removed the safeJsonStringify wrapper. The old WeakSet doubled as a hidden size guard, but it did so by silently corrupting the output for any acyclic graph with shared refs.

I looked at the actual callers (loggers.ts, file-exporters.ts, qwen-logger.ts, message-bus.ts, mcp-tool.ts, tool-registry.ts). They all stringify request/response payloads, tool args, error messages. Flat-ish JSON from the model or typed message structs. None of them currently fan out into a DAG shape that would blow up.

If size-guarded serialization for arbitrary user input becomes a real concern, I think that's a separate enhancement (a max output size or max duplicate count knob) rather than reintroducing the false [Circular]s here. Can revisit as a follow-up if telemetry ever surfaces a caller that needs it.

Comment thread packages/core/src/utils/safeJsonStringify.ts
Comment thread packages/core/src/utils/safeJsonStringify.test.ts
@ihubanov

Copy link
Copy Markdown
Contributor Author

Both correct. Validation section overstated coverage.

file-exporters.test.ts landed in upstream main after my branch base (PR #3630, then expanded in #3642), so it isn't on this PR branch. I ran tests against it locally but from a working tree that was sitting on upstream main, not from a fresh checkout of the PR branch. Pulled it from the validation list.

And yes, qwen-logger.test.ts:42-43 and message-bus.test.ts:27-28 both vi.mock('../utils/safeJsonStringify.js', ...) with native JSON.stringify. Those tests pass regardless of what this PR does. Pulled them from the validation list too.

Files that genuinely cover the change:

  • safeJsonStringify.test.ts (13 tests, direct)
  • loggers.test.ts (45)
  • tool-registry.test.ts (32)
  • mcp-tool.test.ts (53)

143 tests across 4 files actually exercise the new ancestor-stack walk. PR body updated.

wenshao
wenshao previously approved these changes May 21, 2026

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found since prior review. The 3 Suggestions (DAG blowup, deep unwinding test, toJSON test) remain the complete set. LGTM! ✅ — qwen-latest-series-invite-beta-v36 via Qwen Code /review

Four regression tests covering corners the initial five missed:

- Shared leaf reached through five levels of nesting plus a sibling branch.
  Exercises the unwind loop popping multiple frames between the deep arm
  and the sibling arm of the walk.

- Real cycle (root referenced back from depth 5). Same depth as above but
  the deep arm closes the loop, so the ancestor check must still fire.

- Shared object returned by toJSON from two sibling positions. The replacer
  sees the post-toJSON value, so duplicate-ref handling has to recognize
  these as duplicates even though the carriers are different objects.

- Cycle through a toJSON that returns an ancestor. Confirms the ancestor
  check fires on the toJSON return value, not the toJSON-bearing carrier.

Per review feedback on QwenLM#4407.
@wenshao

wenshao commented May 24, 2026

Copy link
Copy Markdown
Collaborator

PR 4407 本地 tmux 验证报告

PR: #4407 fix(core): preserve duplicate object references in safeJsonStringify
作者: @ihubanov
Base: 9de33dded (merge-base on main)
Tip: dad73ac57
验证时间: 2026-05-24
验证环境: macOS Darwin 25.4.0 / tmux 会话 pr4407(5 个验证窗口)


1. 总体结论

维度 结果
Bug 修复 ✅ 兄弟节点/数组元素/多分支共享叶子的重复引用正确序列化
真循环检测 ✅ 自循环、间接循环仍然输出 [Circular]
构建 / typecheck ✅ 全过
Lint ✅ 改动文件零 warning
saferJsonStringify 测试 ✅ 13/13 passed(含 8 个新回归测试)
下游调用方测试 ✅ loggers(45) + tool-registry(32) + mcp-tool(53) 全过
Built artifact E2E ✅ 7 个行为场景全部正确

合并建议:✅ 可以合并。小改动、精准修复、测试覆盖充分、无回归。


2. 验证矩阵

Window 用途 结果
0 tests safeJsonStringify.test.ts + 3 个下游调用方 132 passed, 4 files, 0 failed
1 build npm run build EXIT=0
2 typecheck npm run typecheck(5 包) EXIT=0
3 lint eslint on changed files EXIT=0(0 warning)
4 before-after 导入 built artifact 验证 7 场景 7/7 ✓

3. 根因与修复

3.1 问题

safeJsonStringify 的 replacer 用单一 WeakSet 记录所有见过的对象。JSON.stringify 做 DFS 遍历:第一次遇到对象 A(在第一个分支)时加入 set;遍历完第一个分支后回到祖先,再进入第二个兄弟分支时再遇到对象 A — 此时 A 仍在 set 中,被错误标记为 [Circular]

这不是循环,只是重复引用(同一对象在多个不相关位置)。

3.2 修复

WeakSet 替换为 ancestor-stack 方案:

// Before: 全局 seen set — 无法区分循环 vs 重复引用
const seen = new WeakSet();
(key, value) => {
  if (typeof value === 'object' && value !== null) {
    if (seen.has(value)) return '[Circular]';
    seen.add(value);
  }
  return value;
}

// After: ancestor-stack — 只有真循环才标记
const ancestors: object[] = [];
function (this, _key, value) {
  if (typeof value !== 'object' || value === null) return value;
  // DFS 回溯时弹出已离开的祖先
  while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
    ancestors.pop();
  }
  // 只有值在当前 ancestor chain 上才是真循环
  if (ancestors.includes(value as object)) return '[Circular]';
  ancestors.push(value as object);
  return value;
}

利用 JSON.stringify replacer 中 this = 当前值的父对象这一特性,在每次调用时弹出已离开的祖先,保持栈精确反映当前路径。


4. 测试覆盖

4.1 新增 8 个回归测试

# 测试 说明
1 sibling duplicate refs { a: shared, b: shared } → 两个完整拷贝
2 array duplicate refs [shared, shared, shared] → 三个完整拷贝
3 shared leaf on branches { left: { sub: leaf }, right: { sub: leaf } } → 两个完整叶子
4 indirect cycle parent → child → parent → child 处 [Circular]
5 shared subtree + real cycle 混合:兄弟共享 + 自循环,各自正确处理
6 deep ancestor unwind 5 层深链的兄弟 duplicate — 验证 unwind pop 正确
7 deep cycle 5 层深链的真循环 — 验证深层照样检测
8 toJSON duplicate toJSON() 返回同一对象的两兄弟 → 完整拷贝
9 toJSON cycle toJSON() 返回祖先 → [Circular](实际属于 pre-existing 5 测试之一)

4.2 下游调用方(用真实 safeJsonStringify,非 mock)

文件 测试数 结果
telemetry/loggers.test.ts 45
tools/tool-registry.test.ts 32
tools/mcp-tool.test.ts 53

合计 132 tests, 0 failed。注:qwen-logger.test.tsmessage-bus.test.ts mock 了 safeJsonStringify 为原生 JSON.stringify,不实际调用 — 已确认。

4.3 Built Artifact E2E 行为验证

直接 import packages/core/dist/src/utils/safeJsonStringify.js 验证 7 个场景:

✓ sibling duplicate refs     → 两个完整拷贝,无 [Circular]
✓ array duplicate refs       → 三个完整拷贝,无 [Circular]
✓ shared leaf on branches    → 两个完整叶子,无 [Circular]
✓ true self-cycle            → [Circular] 正确保留
✓ indirect cycle             → 间接路径 [Circular] 正确
✓ shared subtree + real cycle → 混合场景两者都正确
✓ deep ancestor unwind       → 5层深链 sibling 正确,unwind loop 验证通过

5. Behavior Change Impact

唯一输出变化:对于包含重复对象引用(非循环)的 graph,输出从 [Circular] 变为完整序列化。

输入 Before After
{a: shared, b: shared} {"a":{...},"b":"[Circular]"} {"a":{...},"b":{...}}
[shared, shared] [{...},"[Circular]"] [{...},{...}]
{left:{sub:leaf}, right:{sub:leaf}} {...,"right":{"sub":"[Circular]"}} {...,"right":{"sub":{...}}}
realCycle (parent.self=parent) {...,"self":"[Circular]"} 不变

影响面

  • Telemetry / logging — 更完整的数据(之前被 [Circular] 截断的重复 schema/param 现在完整输出)
  • MCP error messages — 共享的 param schema 不再被截断
  • Tool registry toString() — 共享的 tool schema 不再出现 [Circular]
  • 下游 caller — API/签名不变,输出质量提高

6. 代码质量

  • 算法选择:ancestor-stack 是这个问题的最小正确解(O(depth) 空间 vs 之前 O(n) WeakSet)
  • 利用平台行为:JSON.stringify replacer 的 this 语义是 ECMA-262 规范行为,不依赖实现细节
  • while (ancestors.pop()) 回溯逻辑:在每次 replacer 调用时执行,时间复杂度 O(depth) per call,对于深层嵌套最差 O(n²) 但实际对象深度通常 < 100
  • 无新增依赖

7. 合并建议

建议合并

精准的两文件改动(+130/-7),8 个新回归测试覆盖了所有边界(兄弟、数组、深层、toJSON、混合场景),3 个真实下游调用方测试全过,built artifact E2E 验证 7/7。无需任何下游代码修改。


8. 复现指引

# 进入 tmux 会话
tmux attach -t pr4407

# 验证环境
cd /Users/wenshao/Work/git/qwen-code-x4
git branch  # fix/safe-json-stringify-dup-refs

# 运行测试
cd packages/core
npx vitest run --no-coverage src/utils/safeJsonStringify.test.ts \
  src/telemetry/loggers.test.ts \
  src/tools/tool-registry.test.ts \
  src/tools/mcp-tool.test.ts

# E2E 行为验证
node /tmp/pr4407-verify.mjs

# Lint
npx eslint src/utils/safeJsonStringify.ts src/utils/safeJsonStringify.test.ts

报告由 Claude Opus 4.7 在本地 tmux 上完整验证,作为维护者 merge 决策参考。

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

@ihubanov

Copy link
Copy Markdown
Contributor Author

Validation matrix matches what I had locally on the helper + the three real-impl downstream files. The built-artifact E2E pass is more than I'd done myself, good to know it holds at the dist layer.

The DAG-blowup thread is the only one still open. If the no-size-knob-in-this-PR reasoning reads as right to you, feel free to resolve. Otherwise tell me what shape you'd want and I'll fold it in.

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Fix safeJsonStringify incorrectly marking duplicate (non-circular) object references as [Circular] — uses ancestor-path tracking instead of global WeakSet.

@wenshao
wenshao merged commit 9363879 into QwenLM:main May 25, 2026
17 checks passed

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found since prior review. The ancestor-stack algorithm is correct and well-tested. LGTM! ✅ — qwen3.7-max via Qwen Code /review

xaelistic pushed a commit to xaelistic/qwen-code that referenced this pull request Jun 7, 2026
…M#4407)

Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
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.

3 participants