Skip to content

IGNITE-28747 GridToStringBuilder#handleRecursion may cause NPE. Colle…#13262

Open
EgorBaranovEnjoysTyping wants to merge 10 commits into
apache:masterfrom
EgorBaranovEnjoysTyping:IGNITE-28747
Open

IGNITE-28747 GridToStringBuilder#handleRecursion may cause NPE. Colle…#13262
EgorBaranovEnjoysTyping wants to merge 10 commits into
apache:masterfrom
EgorBaranovEnjoysTyping:IGNITE-28747

Conversation

@EgorBaranovEnjoysTyping

Copy link
Copy Markdown
Contributor

Collect toString as tree, avoid extra allocations and problems in recursion resolution
Problems solved:
Any recursive call in toString method would be handled
Allocate less memory
Memory wouldn't be kept for a whole thread life

Copilot AI 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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Refactors Ignite’s toString infrastructure to a node-based implementation with improved recursion handling and enhanced bounded string building, plus adds/extends tests for the new behavior.

Changes:

  • Replaced the previous thread-local SBLimitedLength + recursion map approach with a GridToStringNode tree, recursion monitors, and marker recovery.
  • Extended SBLimitedLength/CircularStringBuilder capabilities (insert/substring, head/tail limits) to support the new toString flow.
  • Added new unit tests for SBLimitedLength and expanded existing toString/circular buffer tests.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
modules/core/src/test/java/org/apache/ignite/testsuites/IgniteUtilSelfTestSuite.java Registers the new SBLimitedLengthSelfTest in the util test suite.
modules/core/src/test/java/org/apache/ignite/internal/util/tostring/SBLimitedLengthSelfTest.java Adds focused tests for append/insert/toString and prohibited operations in SBLimitedLength.
modules/core/src/test/java/org/apache/ignite/internal/util/tostring/GridToStringBuilderSelfTest.java Adds regression tests around recursion and an NPE scenario for the new implementation.
modules/core/src/test/java/org/apache/ignite/internal/util/tostring/CircularStringBuilderSelfTest.java Adds tests for CircularStringBuilder.insert and substring.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/SBLimitedLength.java Updates limited-length builder behavior, adds insert routing logic, forbids mutating reductions, and integrates marker recovery.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/SBLengthLimit.java Adjusts overflow logic and tail creation behavior used by SBLimitedLength.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/NodeRecursionMonitor.java Introduces recursion tracking via a thread-local identity registry.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/Node.java Adds node types and a factory to build a stringification tree with recursion termination and collection/map/array handling.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/GridToStringBuilder.java Refactors to the node-based flow and adds throwable rendering.
modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/CircularStringBuilder.java Adds insert and substring operations; removes reset.
modules/commons/src/main/java/org/apache/ignite/internal/util/GridStringBuilder.java Integrates marker recovery into various append/insert/replace operations and updates javadocs.
Comments suppressed due to low confidence (1)

modules/commons/src/main/java/org/apache/ignite/internal/util/tostring/SBLimitedLength.java:1

  • These i(...) overloads bypass SBLimitedLength's custom insert routing by calling super.i(...), which inserts directly into the head buffer and ignores head/tail limiting. They should delegate to this class’s i(int, String) (or equivalent) so inserts beyond the head limit are correctly redirected/handled.
/*

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 61 to +63
boolean overflowed(SBLimitedLength sb) {
return sb.impl().length() > HEAD_LEN;
return sb.length() >= getHeadLengthLimit();
}
Comment on lines +323 to +325
@Override public GridStringBuilder i(int off, boolean b) {
return super.i(off, String.valueOf(b));
}
Comment on lines +118 to +121
if (childFieldCls.isPrimitive())
return new GridToStringValueNode(childPropName, val);
else if (childFieldCls.isArray())
return new GridToStringArrayNode(childPropName, (Object[])val, childFieldCls);
nodes = new GridToStringNode[Math.min(COLLECTION_LIMIT, arr.length)];
for (int i = 0; i < nodes.length; i++) {
final int idx = i;
nodes[idx] = getGridToStringNode(null, () -> arr[idx], () -> arr[idx].getClass());
Comment on lines +58 to +59
Object obj = iter.next();
GridToStringNode node = getGridToStringNode(null, () -> obj, obj::getClass);
Comment on lines +53 to +55
void aqcuireRecursionMonitor(NodeRecursionMonitor node) {
OBJECT_REGISTRY.get().put(obj, node);
}
Comment on lines +38 to +39
public static final ConcurrentHashMap<Thread, IdentityHashMap<String, GridToStringNode>> CATCHED_NODES
= new ConcurrentHashMap<>();
Comment on lines +236 to +242
public String substring(int beginIdx, int endIdx) {
if (beginIdx < 0 || endIdx < 0 || endIdx > skipped + length())
throw new StringIndexOutOfBoundsException(
"Some of indexes is out of bounds. Begind index = " + beginIdx + " end index = " + endIdx);
if (beginIdx > endIdx)
throw new IllegalArgumentException(
"Begin index can not be greater then end index (begin = " + beginIdx + " end = " + endIdx + ")");
Comment on lines +381 to +383
@Override public void setLength(int len) {
throw new UnsupportedOperationException("setLength is not supported by this imlementation");
}
Comment on lines +210 to +224
@Test
public void testNPE() {
boolean success;
try {
SBLimitedLength sbLimitedLength = new SBLimitedLength(256);
sbLimitedLength.initLimit(new SBLengthLimit());
sbLimitedLength.a("a".repeat(7999));
sbLimitedLength.i(7000, "asd");
sbLimitedLength.a("a".repeat(10));
success = true;
} catch (NullPointerException ignored) {
success = false;
}
Assert.assertTrue(success);
}

@anton-vinogradov anton-vinogradov 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.

Thanks for digging into this — the NPE is real and the diagnosis is correct. My concern is that a small, local defect is being fixed with a full rewrite of the toString engine, and the rewrite introduces problems that are worse than the original bug.

Root cause (small and local). SBLimitedLength overrides every a(...) (append) but none of the i(...) (insert) methods. handleRecursion() calls buf.i(pos, hash), which writes straight into impl() and bypasses onWrite() — the only place that allocates tail. The insert pushes the head past HEAD_LEN, so overflowed() flips to true while tail is still null, and the next a(savedName) takes the overflow branch -> tail.append(...) -> NPE. The RecursivePayload test reproduces exactly this.

The minimal fix is already in this PR. The i(...) overrides + lazy-tail in SBLimitedLength are the correct fix and are enough to close the ticket on their own (~2 files + the new test). Everything else — the GridToStringNode tree, NodeRecursionMonitor, the marker/recover mechanism, gutting GridToStringBuilder by ~685 lines — is an unrelated refactor.

Blocking concerns with the refactor (details inline):

  1. Marker/recover breaks the toString() contract. A nested S.toString() returns a marker string (new String("GridToStringNode")) instead of its value, recovered later by String identity. Any wrapping — "Wrapper{" + S.toString(...) + "}", very common in our code — defeats the identity lookup and leaks the literal GridToStringNode into the log. recoverObject's own javadoc concedes the design can't guarantee correctness.
  2. Thread-keyed static map leaks. static ConcurrentHashMap<Thread, ...> CATCHED_NODES pins Thread keys (and captured nodes) whenever clear() is skipped on any path. The original ThreadLocal was leak-free by construction; this replaces it with a slower, leak-prone map of identical semantics.
  3. toString() can now throw. markNode() ends with identities().orElseThrow(); on a path where init() didn't run, an inner toString() throws — the exact failure mode the original blanket try/catch existed to prevent.

Also: the tree is built in full (reflection + recursion + each field's toString()) and only length-limited at render time, which defeats the whole point of SBLimitedLength (cap the cost of toString on large graphs). The PR claims fewer allocations but adds per-node objects, Optional/lambda chains, a per-call new SBLimitedLength, and a recoverObject lookup on every append — with no benchmark.

Proposal: reduce this PR to the SBLimitedLength insert overrides + lazy tail + the RecursivePayload test. If we genuinely want to cut toString allocations, let's do it as a separate ticket with JMH before/after and an explicit story for large graphs and wrapped toString().

List<GridToStringNode> addNodes =
GridToStringNodeFactory.getNodes(addNames, addVals, addSens, addLen);
GridToStringNode node = GridToStringNode.getRootNode(obj, cls, addNodes);
return isNew ? node.toString() : GridToStringNode.markNode(node);

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.

Core of the marker mechanism: every non-root S.toString() now returns a placeholder marker instead of its value, stitched back later by String identity. This only holds if the caller's toString() returns exactly that instance untouched. As soon as a class does "Foo{" + S.toString(Foo.class, this) + "}" (common across the codebase), the marker is embedded in a larger string, identity recovery fails, and the literal GridToStringNode leaks into the log. This silently corrupts output and is the main reason I'd not take the rewrite.

* @return The unique marker string.
*/
static String markNode(GridToStringNode node) {
String result = new String(GridToStringNode.class.getSimpleName());

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.

Two problems on this method:

  1. Using new String(...) identity as a map key is extremely fragile (see the wrapped-toString case).
  2. orElseThrow() makes markNode throw if the thread state wasn't initialized (a nested call on a path where init() didn't run). toString() must never throw — the original guarded every path with try/catch; this reintroduces throwing from toString.

* A thread-local cache for nodes, used to handle references of
* inner toString() calls by mapping temporary markers to actual nodes.
*/
static final ConcurrentHashMap<Thread, IdentityHashMap<String, GridToStringNode>> CATCHED_NODES

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.

static ConcurrentHashMap<Thread, ...> keyed by Thread is a classic leak: in a pooled-thread server the keys never die, and any path that skips clear() (exception in a user toString, reentrancy) accumulates entries that pin the Thread and all captured nodes/objects. The value is a per-thread map touched only by its owner, so the ConcurrentHashMap buys nothing but overhead. This should be a ThreadLocal — which is what the original used and why it didn't leak. (nit: CATCHED -> CACHED.)

* we should replace the substring
* @param obj Object.
*/
public static <T> T recoverObject(T obj) {

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.

recoverObject is a band-aid for the marker design, and it only handles the case where the entire returned string is the marker — not the prefix + marker + suffix concatenation, which is exactly the dangerous case. The javadoc ('if someone decided to concat outside of default S.toString') is effectively an admission the mechanism can't be made correct. It's also now invoked on every a(...)/i(...) in SBLimitedLength, adding an identity-map lookup to every append on the render path.

else if (val instanceof Map)
return new GridToStringMapNode(childPropName, (Map<?, ?>)val);

String toStrResult = val.toString();

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 is where wrapped/custom toString() breaks: if val.toString() returns anything other than the exact marker instance, recoverOrCreate can't match it and the marker text is treated as a literal value. Net effect for a class that wraps S.toString(): the inner object renders as GridToStringNode.

Object[] addVals,
boolean[] addSens,
int addLen) {
List<GridToStringNode> result = new LinkedList<>();

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.

nit: ArrayList is a better default here (indexed iteration, less per-node overhead). LinkedList + Collections.unmodifiableList per object adds up on the hot path.

* Registers the current node in the thread-local registry for the given object.
* @param node The node to register.
*/
void aqcuireRecursionMonitor(NodeRecursionMonitor node) {

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.

nit: typo aqcuireRecursionMonitor -> acquireRecursionMonitor.


/** {@inheritDoc} */
@Override public void setLength(int len) {
throw new UnsupportedOperationException("setLength is not supported by this imlementation");

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.

nit: typo imlementation -> implementation.

public String substring(int beginIdx, int endIdx) {
if (beginIdx < 0 || endIdx < 0 || endIdx > skipped + length())
throw new StringIndexOutOfBoundsException(
"Some of indexes is out of bounds. Begind index = " + beginIdx + " end index = " + endIdx);

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.

nit: typo Begind index -> Begin index.

}

/** {@inheritDoc} */
@Override public GridStringBuilder i(int offset, String str) {

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 is the correct fix for the NPE. My suggestion is to ship this (the insert overrides + lazy tail) plus the RecursivePayload test as the entire patch, drop recoverObject from it, and split the node-tree work into a separate ticket/RFC.

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