-
Notifications
You must be signed in to change notification settings - Fork 2.9k
fix(core): preserve duplicate object references in safeJsonStringify #4407
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,11 @@ | |
| /** | ||
| * Safely stringifies an object to JSON, handling circular references by replacing them with [Circular]. | ||
| * | ||
| * Only true cycles (an object reachable from itself along the current ancestor | ||
| * path) are replaced. Duplicate references (the same object appearing in | ||
| * multiple sibling positions) are preserved as full copies, matching the | ||
| * behavior of `JSON.stringify` on acyclic graphs. | ||
| * | ||
| * @param obj - The object to stringify | ||
| * @param space - Optional space parameter for formatting (defaults to no formatting) | ||
| * @returns JSON string with circular references replaced by [Circular] | ||
|
|
@@ -15,16 +20,23 @@ export function safeJsonStringify( | |
| obj: unknown, | ||
| space?: string | number, | ||
| ): string { | ||
| const seen = new WeakSet(); | ||
| const ancestors: object[] = []; | ||
| return JSON.stringify( | ||
| obj, | ||
| (key, value) => { | ||
| if (typeof value === 'object' && value !== null) { | ||
| if (seen.has(value)) { | ||
| return '[Circular]'; | ||
| } | ||
| seen.add(value); | ||
| function (this: unknown, _key, value) { | ||
| if (typeof value !== 'object' || value === null) { | ||
| return value; | ||
| } | ||
| // `this` is the parent of `value`. As JSON.stringify's DFS walk unwinds | ||
| // back up the tree, pop any ancestors that are no longer on the path | ||
| // to `this` so the stack reflects only the current chain of ancestors. | ||
| while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { | ||
|
ihubanov marked this conversation as resolved.
|
||
| ancestors.pop(); | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] DAG with shared refs can produce exponentially large output. The old This matters because 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
| if (ancestors.includes(value as object)) { | ||
| return '[Circular]'; | ||
| } | ||
| ancestors.push(value as object); | ||
| return value; | ||
| }, | ||
| space, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.