Skip to content

fix(output): make the json default hook single-pass - #761

Merged
annehe9 merged 4 commits into
mainfrom
anne/json-default-single-pass
Aug 24, 2026
Merged

fix(output): make the json default hook single-pass#761
annehe9 merged 4 commits into
mainfrom
anne/json-default-single-pass

Conversation

@annehe9

@annehe9 annehe9 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

What

json.dumps re-encodes whatever a default hook returns and calls the hook again when the result is still not serializable, so a default that returns a non-JSON value never terminates. _json_default returned obj.isoformat() unchecked. Any stub or mock answers hasattr(obj, "isoformat") and returns another object of its own kind, so each pass allocated a new object and re-entered.

The rule the hook has to hold is that every return is a value json can already encode. Two branches broke it:

  • isoformat() was returned unchecked, so a stub that returns a fresh object of its own kind produced a new object per pass and never terminated.
  • the Enum branch returned obj.value unchecked, so an opaque member value came straight back into the hook, and an Enum wrapping an Enum chained.

Both are now coerced, so the hook runs exactly once per value. There is no recursion depth left to bound, so no RecursionError to raise, catch, or swallow. A test asserts the single-pass property directly by counting hook invocations rather than by checking that some depth limit stops it.

How it showed up

tests/comfy_cli/command/test_run.py::TestLocalExecuteItemMapAndGroupedOutputs::test_local_wait_envelope_groups_by_node_and_item never finished on a local checkout. It reaches _emit_queued, which puts execution.validation_warnings and execution.workflow_manifest() into the envelope; that test's mock leaves both as auto-created mocks.

CI never caught it because both test workflows pin Python 3.10, where the C encoder's recursion guard raises RecursionError after ~978 nested default calls — and RecursionError is an Exception, so the bare except Exception: pass inside _json_default swallowed it and the encode limped to a string. Measured: 3.10.20 finishes after 978 calls, 3.11.15 after 979, while 3.12, 3.13 and 3.14 had to be killed by a watchdog. pyproject.toml declares requires-python = ">=3.10", so anyone on a newer interpreter hits it.

Verification

  • Before: on clean main, that test hangs indefinitely (killed at 40s). A leaked probe process reached 43% of system RAM, so an unattended run can push a machine into swap.
  • After: the whole class passes in 0.22s. tests/comfy_cli/output/test_renderer.py 56 passed.
  • Full suite with nothing deselected: 5645 passed, 43 failed in 4m37s. Those 43 are the pre-existing local-env failures (test_logs, test_jobs, test_host_port, test_onboarding, test_pretty_print_sanitize) that also fail on main. Before this change the suite could not complete locally at all.
  • New tests fail without the fix and fail fast, so they can never wedge CI: the isoformat case raises ValueError: Circular reference detected, and the single-pass counter sees 4 hook calls for 3 values.
  • ruff check . clean.

Follow-ups (not in this PR)

  • Add a 3.13 or 3.14 leg to the test matrix; requires-python claims support past 3.10 and nothing verifies it.
  • Consider pytest-timeout in the dev extra. It is not installed today, so a hang in CI would burn the whole job timeout instead of failing fast.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 47b552e1-8fe3-4722-b4f6-2d0060e2eabc

📥 Commits

Reviewing files that changed from the base of the PR and between 3c423d8 and e28ff19.

📒 Files selected for processing (2)
  • comfy_cli/output/renderer.py
  • tests/comfy_cli/output/test_renderer.py

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

Changes

This change updates _json_default to handle nested and cyclic enum values, isoformat() results, JSON-native values, and fallback conversion. It adds serializer tests and revises two section comments.

JSON serialization and terminology

Layer / File(s) Summary
JSON fallback contract and tests
comfy_cli/output/renderer.py, tests/comfy_cli/output/test_renderer.py
_json_default safely unwraps nested enum values, handles isoformat() results, preserves JSON-native values, and converts unsupported values to strings. Tests cover datetime, path, enum, container, cyclic, and fallback behavior.
Status and test heading wording
comfy_cli/cloud/command.py, tests/comfy_cli/command/test_workflow_edit.py
Section comments use revised wording for status commands and name-addressed set-widget tests.

Suggested reviewers: skishore23

Merge Risk: ⚪ Minimal · up to e28ff

This change makes JSON default handling terminate in a single pass and adds focused coverage; no actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch anne/json-default-single-pass
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch anne/json-default-single-pass

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

annehe9 and others added 3 commits August 23, 2026 09:43
`json.dumps` feeds a `default` hook's return value back through the
encoder and calls the hook again when that value still is not
serializable, so the hook only terminates if it returns something json
can already encode. Two branches broke that rule.

`isoformat()` was returned unchecked. Any stub or mock answers
`hasattr(obj, "isoformat")` and returns a fresh object of its own kind,
so each pass allocated another object and re-entered forever. The
`Enum` branch returned `obj.value` unchecked, so an opaque member value
came straight back in, and an `Enum` wrapping an `Enum` chained a hop
per level.

Both are coerced now, so the hook runs exactly once per value and there
is no recursion depth left to bound. The new test asserts that directly
by counting hook invocations.

This hung `tests/comfy_cli/command/test_run.py::TestLocalExecuteItemMapAndGroupedOutputs`
on Python 3.12 and newer, where the encoder no longer trips a recursion
guard early. On 3.10 and 3.11 that guard raised RecursionError after
~978 nested calls and the bare `except Exception` here swallowed it, so
the encode limped to a string and CI, pinned to 3.10, stayed green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The single-pass guarantee was enforced twice, once inside the `Enum` branch
and once inside the `isoformat` branch, so each new branch would have to
carry its own guard. Coercing once at the end states the rule in one place
and holds it for any branch added above.

The explicit `Path` branch and its local import go with it: the tail already
turns a `Path` into its string, which is what that branch returned.

Behaviour is unchanged except that an `Enum` whose value is a datetime now
serializes as its ISO string rather than `str(datetime)`.
ERA001 landed on main with two hits still in the tree, so `ruff check` is
red on main and on every branch cut from it. `# status (billing / plan /
concurrency)` and `# set-widget (name-addressed)` both parse as call
expressions, which is what the rule looks for.

Reworded the same way #735 handled `# browse: types / categories`, so the
dividers keep saying what they said without looking like code.

(cherry picked from commit 31be2b0)
@annehe9
annehe9 force-pushed the anne/json-default-single-pass branch from 62ebc50 to 3c423d8 Compare August 23, 2026 16:44
@annehe9
annehe9 marked this pull request as ready for review August 23, 2026 19:55
@dosubot dosubot Bot added size:S This PR changes 10-29 lines, ignoring generated files. bug Something isn't working labels Aug 23, 2026
@annehe9 annehe9 added the cursor-review Request Cursor bot review label Aug 23, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cursor Review — Consolidated panel

Triggered by @annehe9.

Found 2 finding(s).

Severity Count
🟡 Medium 1
🟢 Low 1

Panel: 8/8 reviewers contributed findings.

Comment thread comfy_cli/output/renderer.py Outdated
except Exception: # noqa: BLE001
pass
return str(obj)
return obj if isinstance(obj, str | int | float | None) else str(obj)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium — The tail whitelist admits only str/int/float/None, so JSON-native containers are now stringified: an Enum whose value is [1, 2] or {'a': 1} used to be handed back to json and encoded as a real array/object, and now emits the Python repr "[1, 2]" / "{'a': 1}", silently changing machine-mode output shape (the hook is shared by selector.py::_dumps, so --select output is affected too). The docstring's promise that the tail "holds for any branch added above it" makes this a trap for the next natural branch, e.g. a dataclass/to_dict -> dict coercion, which would be flattened to a repr string. Letting list/dict/tuple through the check (json encodes tuples as arrays) preserves the previous behavior. Raised by 6 of 8 reviewers (gpt-5.6-sol-max edge-case, claude-opus-5-thinking-max edge-case, claude-opus-5-thinking-max adversarial, gemini-3.1-pro edge-case, gemini-3.1-pro adversarial, kimi-k3-max edge-case).

"""
if isinstance(obj, Enum):
return obj.value
obj = obj.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Low — Enum unwrapping is now single-level: previously return obj.value handed an inner Enum member back to json, which re-entered the hook and unwrapped down to the innermost scalar, whereas now the inner member falls through to str(obj) and serializes as "Inner.MEMBER". A while isinstance(obj, Enum): obj = obj.value loop restores the old output while keeping the single-pass guarantee this redesign is after. Raised by 5 of 8 reviewers (claude-opus-5-thinking-max edge-case, kimi-k3-max edge-case, kimi-k3-max adversarial, gemini-3.1-pro adversarial, gpt-5.6-sol-max edge-case).

The single-pass rewrite narrowed the tail coercion to str/int/float/None,
which stringified values json already encodes. An Enum whose value is a
list or a dict came back as its Python repr instead of a real array or
object, changing machine-mode output shape. `selector.py::_dumps` shares
the hook, so `--select` output changed too. The tail now passes list,
dict and tuple straight through; their members are separate values, so
each still costs at most one further hook call.

The same rewrite also made enum unwrapping single-level, so an Enum
wrapping an Enum serialized as "Inner.MEMBER" rather than the innermost
scalar. Unwrapping now loops, bounded by an id-seen set so a member
whose `_value_` was mutated into a cycle cannot hang the encode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019deuWey4LoCRaxADqop4nB
@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Aug 24, 2026
@annehe9
annehe9 merged commit b7f808e into main Aug 24, 2026
18 of 19 checks passed
@annehe9
annehe9 deleted the anne/json-default-single-pass branch August 24, 2026 19:11
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 24, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

bug Something isn't working cursor-review Request Cursor bot review lgtm This PR has been approved by a maintainer size:S This PR changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants