Skip to content

Refuse a returned tree that reaches a node twice - #388

Merged
jserv merged 1 commit into
sysprog21:mainfrom
quill0224:reject-cyclic-tree
Oct 22, 2026
Merged

jserv merged 1 commit into
sysprog21:mainfrom
quill0224:reject-cyclic-tree

Conversation

@quill0224

@quill0224 quill0224 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

A returned binary tree that links back to an earlier node used to stall every runner: the tree serializers walk breadth first with no record of visited nodes, so JavaScript and Python timed out with "check for an infinite loop" in code that had already returned, Java was killed with SIGKILL, and C overran Compiler Explorer's output limit, with no case reporting anything. treeToArray, _tree_to_array, C's json_tree, C++'s toJson(TreeNode*) and Java's json(TreeNode) now refuse a node they reach a second time with "Returned tree must not reach a node twice.", and the cases without that defect still report their own results. C++ was missing from the issue's table but has the same serializer, so it is included.

The issue proposed "Returned tree must not contain a cycle.". A visited set cannot tell a cycle from two parents sharing one subtree, and neither is a tree, so the check refuses both and the message names what it saw rather than claiming a cycle. That settles follow-up 4 of the issue. Rejecting only cycles would need the ancestor path in all five serializers. C has no set, so json_tree scans the nodes it has already dequeued, and it reports the refusal as the case's error the way json_list does since #405. The scan is quadratic but bounded by the 4096-entry queue: a 2047-node chain, the largest the queue holds, takes about 13 ms at -O0.

The tests run the issue's cyclic flatten, the last Morris thread left in place, and expect the message as the error of the three cases with more than one node and the normal result on the empty and single-node cases. Two more flattens give every case with a root two children holding the root's value. One hangs the same node on both sides and must be refused, which is the shared child a cycle-only check would let through. The other uses two distinct nodes and must print as [v,v,v], which keeps the check keyed on the node rather than on val. tests/browser/compiler-explorer.test.js compiles and runs all three locally with cc, g++ and javac and checks the case count. scripts/browser-check.cjs runs them on the page in JavaScript and Python and checks the summary count.

Validation on macOS 26.5 over 3198a99:

  • node --test --test-name-pattern='returned tree' tests/browser/compiler-explorer.test.js: 9 pass. With the serializers from main, the three cycle tests fail (C aborts with SIGABRT, C++ and Java are killed by the 10 s timeout) and so do the three shared-child tests. Refusing only a link back to the root, which lets a shared child through as an ancestor-path check would, still passes the cycle tests and fails the three shared-child tests. Comparing val instead of the node fails the three distinct-children tests. Printing C's refusal as the case's value instead of its error fails both C refusal tests.
  • BROWSER_CHECK_AGENT=offline scripts/browser-check.sh passes. Reverting the JavaScript check fails it at scripts/browser-check.cjs:1658 and reverting only the Python check at scripts/browser-check.cjs:1702, the two cycle runs. Rethrowing the refusal from the per-case catch in web/runners.js still shows the message but fails the summary count at scripts/browser-check.cjs:1663. Refusing only a link back to the root fails the shared-child runs called at lines 1664 (JavaScript) and 1708 (Python), and comparing val fails the distinct-children runs called at lines 1673 and 1718.
  • ./scripts/test.sh passes. Not checked by this run: commentflow, ruff, shfmt, shellcheck, cargo-audit and actionlint are not installed, and the pinned Clang lane runs only on Linux x86_64.

Left out: the bound on json_tree's queue, tracked in #408 and fixed in #422. A returned tree with 2048 or more nodes still writes queue[4096] here, cycle or not, before the walk dequeues a repeated node. The next-pointer and quad tree serializers have no visited check either, tracked in #428.

Closes #386


Summary by cubic

Tree serializers in all five runners now refuse a returned tree that reaches a node twice, instead of walking it forever and leaving every case unreported or the run killed.

The serializers walked breadth first with no record of visited nodes. A visited set cannot tell a cycle from two parents sharing one subtree, and neither is a tree, so the check refuses both and the message names the repeated visit rather than claiming a cycle.

Notes

  • Cases without the defect still report their own results.
  • C has no exceptions, so its harness prints the message as the case's value.
  • C++ is covered too, although it was missing from the issue's table.

Closes #386.

Written for commit c84bfe0. Summary will update on new commits.

View guided diff

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread web/compiler-explorer.js Outdated
Comment thread scripts/browser-check.cjs
`);
await page.getByRole("button", { name: /Run tests/ }).click();
await page.getByRole("button", { name: "Run tests" }).waitFor();
await treeReachedTwice.waitFor();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This only proves the refusal text shows up somewhere in #results-body. The description's main claim, that the cases without the defect ([] and [0]) still report their own results, is checked only for C, C++ and Java in tests/browser/compiler-explorer.test.js. For JavaScript and Python, a regression that moved the throw outside the per-case try would still pass this check. Asserting the summary count here, as the two-sum check earlier in this file does, covers that. The Python run at the second treeReachedTwice.waitFor needs the same line with { timeout: 120000 }.

Suggested change
await treeReachedTwice.waitFor();
await treeReachedTwice.waitFor();
await page
.getByText(
`Test results · 2/${scenario(PROBLEM_IDS.flattenTree).caseCount}`,
)
.waitFor();

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.

Added the summary count after both runs, with the 120 s timeout on the Python one. To check that it bites, I rethrew the refusal from the per-case catch in web/runners.js: the previous check still passed and the new one fails at the JavaScript count. Rebased onto f3ae86c.

jserv

This comment was marked as outdated.

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rebase latest main branch and resolve conflicts.

Comment thread web/compiler-explorer.js Outdated
// so the run timed out or was killed and no case reported anything. The
// single-node and empty cases have no thread to leave, so they still pass.
// The timeout keeps a regression from hanging the lane.
const cyclicFlatten = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every new case builds a cycle, but the description's point is that the visited set also refuses two parents sharing one subtree. Nothing pins that down. Replacing seen with an ancestor-path check would still pass these tests and the browser check while no longer refusing a shared child. Add a case that sets root.left = root.right = child and expects the refusal as the case's error in all five runners. Also add a tree with equal values in distinct nodes that must still serialize, so the check stays keyed on node identity rather than val.

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.

Added both to all five runners. Every case with a root now also returns two children holding the root's value: the same node on both sides must come back as the case's error, and two distinct nodes must print as [v,v,v]. tests/browser/compiler-explorer.test.js covers C, C++ and Java, and scripts/browser-check.cjs covers JavaScript and Python. Refusing only a link back to the root, which lets a shared child through the way an ancestor-path check would, keeps the cycle tests passing and fails the shared-child test in all five runners. Comparing val instead of the node fails the distinct-children test in all five.

Comment thread web/runners.js
};
const treeFromArray = treeBuilder(TreeNode);
const nextTreeFromArray = treeBuilder(NextNode);
// A node reached twice is a cycle or a shared subtree. The walk below

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This guard covers binaryTree output only. nextTreeToLevels, _next_tree_to_levels and C's json_next_tree walk left/right with no visited set. A linkRowNeighbors that sets root.left = root and leaves root.next = null passes the next-pointer check on every level and loops until the worker times out. That is the same failure #386 reported. quadTreeToPreorder and its C/C++ counterparts recurse into a self-linked child until the stack overflows, which kills every C/C++ case. Worth a follow-up issue that applies the same identity check to those serializers.

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.

Filed #428 for the next-pointer and quad tree serializers, leaving C's json_next_tree to #373. For the quad side it proposes refusing only a node that is its own ancestor, because an answer that reuses one leaf for every uniform quarter passes all five Terrain Mask Tiling cases today and a visited set would refuse it.

The tree serializers in all five runners walked the returned tree
breadth first without remembering which nodes they had visited, so a
tree that linked back to an earlier node kept the walk going until the
run was cut off. Browser runs timed out with advice to look for an
infinite loop in code that had already returned, Java was killed by
Compiler Explorer, and C overran its output limit, so no case reported
anything. Each serializer now refuses the second visit with the same
message. That also refuses two parents sharing one subtree, which is
not a tree either and costs nothing more to catch, so the message names
the repeated visit rather than a cycle.

Closes sysprog21#386
@jserv
jserv merged commit 9bc1eae into sysprog21:main Oct 22, 2026
10 checks passed
@jserv

jserv commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Thank @quill0224 for contributing!

@quill0224
quill0224 deleted the reject-cyclic-tree branch October 27, 2026 13:48
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.

A returned binary tree with a cycle loses every test result instead of failing

2 participants