This is an automated email from the ASF dual-hosted git repository.
garydgregory pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/commons-lang.git
The following commit(s) were added to refs/heads/master by this push:
new f004a9cb4 [LANG-1839] Fix recursive toString traversal cleanup (#1855)
f004a9cb4 is described below
commit f004a9cb42be7d35f66b73faa96616ce482e531b
Author: Gary Gregory <[email protected]>
AuthorDate: Sun Nov 1 10:16:32 2026 -0500
[LANG-1839] Fix recursive toString traversal cleanup (#1855)
* [LANG-1839] Fix recursive toString visited state cleanup
Prevent abandoned builders from retaining object graphs rendered by
unrelated recursive toString calls on the same thread.
Track visited state per output buffer and share it across nested builder
and style calls. Clear completed or failed builder state independently
of REGISTRY, and use weak identity references to avoid retaining
abandoned output graphs.
Add regression coverage for manual appends, nested and interleaved
builders, direct style calls, exceptions, and weak-reference cleanup.
* [LANG-1839] Fix recursive toString visited state cleanup
Add regression tests for recursive toString traversal.
Cover long cycles, shared references, mixed containers, traversal
cleanup,
thread isolation, and collection of abandoned output buffers.
Document the expected behavior and why each test detects a regression.
Assisted-By: OpenAI Codex
---
.../lang3/builder/RecursiveToStringStyle.java | 50 +-
.../lang3/builder/ReflectionToStringBuilder.java | 27 +-
.../commons/lang3/builder/ToStringBuilder.java | 211 +++----
.../commons/lang3/builder/ToStringStyle.java | 293 +++++++++-
.../builder/ReflectionToStringBuilderTest.java | 39 ++
.../lang3/builder/ToStringBuilderCycleTest.java | 352 ++++++++++++
.../ToStringBuilderTraversalConcurrencyTest.java | 203 +++++++
.../ToStringBuilderTraversalMemoryTest.java | 171 ++++++
.../builder/ToStringBuilderTraversalTest.java | 632 +++++++++++++++++++++
9 files changed, 1827 insertions(+), 151 deletions(-)
diff --git
a/src/main/java/org/apache/commons/lang3/builder/RecursiveToStringStyle.java
b/src/main/java/org/apache/commons/lang3/builder/RecursiveToStringStyle.java
index 7f361026a..3e0e463b4 100644
--- a/src/main/java/org/apache/commons/lang3/builder/RecursiveToStringStyle.java
+++ b/src/main/java/org/apache/commons/lang3/builder/RecursiveToStringStyle.java
@@ -169,6 +169,16 @@ protected boolean accept(final Class<?> clazz) {
// @formatter:on
}
+ @Override
+ public void append(final StringBuffer buffer, final String fieldName,
final Object[] array, final Boolean fullDetail) {
+ final boolean owner = startStyleTraversal(buffer);
+ try {
+ super.append(buffer, fieldName, array, fullDetail);
+ } finally {
+ endStyleTraversal(owner);
+ }
+ }
+
@Override
protected void appendDetail(final StringBuffer buffer, final String
fieldName, final Collection<?> coll) {
appendClassName(buffer, coll);
@@ -189,30 +199,32 @@ public void appendDetail(final StringBuffer buffer, final
String fieldName, fina
* {@inheritDoc}
*
* <p>
- * In addition to the cycle check performed by the superclass, this
implementation keeps a
- * per-thread set of objects already rendered in detail during the current
top-level call.
- * Values that this style would traverse structurally (see {@link
#accept(Class)}) are rendered
- * in detail at most once; later identity-equal occurrences are appended
in the abbreviated
- * {@code Object.toString()} format. This bounds the traversal to one
visit per object, so
- * shared (acyclic) references cannot cause exponential re-traversal. If
an output-length limit
- * was configured and the buffer has reached it, a {@code
"...<truncated>"} marker is appended
- * instead of the value.
+ * In addition to the cycle check performed by the superclass, this
implementation shares a visited-object set across nested builders in the active
+ * traversal. The outer builder retains the set between appends and
releases it on completion. Values that this style would traverse structurally
+ * (see {@link #accept(Class)}) are rendered in detail at most once; later
identity-equal occurrences are appended in the abbreviated
+ * {@code Object.toString()} format. This bounds the traversal to one
visit per object, so shared (acyclic) references cannot cause exponential
+ * re-traversal. If an output-length limit was configured and the buffer
has reached it, a {@code "...<truncated>"} marker is appended instead of the
value.
* </p>
*/
@Override
protected void appendInternal(final StringBuffer buffer, final String
fieldName, final Object value, final boolean detail) {
- if (detail && value != null && accept(value.getClass())
- && !(value instanceof Number || value instanceof Boolean ||
value instanceof Character)) {
- if (isVisited(value)) {
- appendCyclicObject(buffer, fieldName, value);
- return;
- }
- if (maxOutputLength > 0 && buffer.length() >= maxOutputLength) {
- buffer.append(TRUNCATED_TEXT);
- return;
+ final boolean owner = startStyleTraversal(buffer);
+ try {
+ if (detail && value != null && accept(value.getClass())
+ && !(value instanceof Number || value instanceof Boolean
|| value instanceof Character)) {
+ if (isVisited(value)) {
+ appendCyclicObject(buffer, fieldName, value);
+ return;
+ }
+ if (maxOutputLength > 0 && buffer.length() >= maxOutputLength)
{
+ buffer.append(TRUNCATED_TEXT);
+ return;
+ }
+ markVisited(value);
}
- markVisited(value);
+ super.appendInternal(buffer, fieldName, value, detail);
+ } finally {
+ endStyleTraversal(owner);
}
- super.appendInternal(buffer, fieldName, value, detail);
}
}
diff --git
a/src/main/java/org/apache/commons/lang3/builder/ReflectionToStringBuilder.java
b/src/main/java/org/apache/commons/lang3/builder/ReflectionToStringBuilder.java
index 97f668119..63d91928e 100644
---
a/src/main/java/org/apache/commons/lang3/builder/ReflectionToStringBuilder.java
+++
b/src/main/java/org/apache/commons/lang3/builder/ReflectionToStringBuilder.java
@@ -728,7 +728,7 @@ public boolean isExcludeNullValues() {
* @return {@code this} instance.
*/
public ReflectionToStringBuilder reflectionAppendArray(final Object array)
{
- getStyle().reflectionAppendArrayDetail(getStringBuffer(), null, array);
+ appenTraversal(() ->
getStyle().reflectionAppendArrayDetail(getStringBuffer(), null, array));
return this;
}
@@ -822,19 +822,22 @@ public void setUpToClass(final Class<?> clazz) {
*/
@Override
public String toString() {
- if (getObject() == null) {
- return getStyle().getNullText();
- }
-
- validate();
-
- Class<?> clazz = getObject().getClass();
- appendFieldsIn(clazz);
- while (clazz.getSuperclass() != null && clazz != getUpToClass()) {
- clazz = clazz.getSuperclass();
+ final boolean owner = startTraversal();
+ try {
+ if (getObject() == null) {
+ return getStyle().getNullText();
+ }
+ validate();
+ Class<?> clazz = getObject().getClass();
appendFieldsIn(clazz);
+ while (clazz.getSuperclass() != null && clazz != getUpToClass()) {
+ clazz = clazz.getSuperclass();
+ appendFieldsIn(clazz);
+ }
+ return super.toString();
+ } finally {
+ endTraversal(owner);
}
- return super.toString();
}
/**
diff --git
a/src/main/java/org/apache/commons/lang3/builder/ToStringBuilder.java
b/src/main/java/org/apache/commons/lang3/builder/ToStringBuilder.java
index 5def29602..6da8080d4 100644
--- a/src/main/java/org/apache/commons/lang3/builder/ToStringBuilder.java
+++ b/src/main/java/org/apache/commons/lang3/builder/ToStringBuilder.java
@@ -307,12 +307,18 @@ public static void setDefaultStyle(final ToStringStyle
style) {
*/
private final ToStringStyle style;
+ /**
+ * Visited-object tracking for this builder's output buffer.
+ */
+ private final ToStringStyle.Traversal traversal;
+
private ToStringBuilder(final Builder builder) {
super(builder);
this.style = builder.style != null ? builder.style : getDefaultStyle();
this.buffer = builder.buffer != null ? builder.buffer : new
StringBuffer(512);
this.object = builder.object;
- style.appendStart(buffer, object);
+ this.traversal = ToStringStyle.getTraversal(buffer);
+ appenTraversal(() -> style.appendStart(buffer, object));
}
/**
@@ -369,8 +375,7 @@ public ToStringBuilder(final Object object, final
ToStringStyle style, final Str
* @return {@code this} instance.
*/
public ToStringBuilder append(final boolean value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -381,8 +386,7 @@ public ToStringBuilder append(final boolean value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final boolean[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -393,8 +397,7 @@ public ToStringBuilder append(final boolean[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final byte value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -405,8 +408,7 @@ public ToStringBuilder append(final byte value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final byte[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -417,8 +419,7 @@ public ToStringBuilder append(final byte[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final char value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -429,8 +430,7 @@ public ToStringBuilder append(final char value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final char[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -441,8 +441,7 @@ public ToStringBuilder append(final char[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final double value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -453,8 +452,7 @@ public ToStringBuilder append(final double value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final double[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -465,8 +463,7 @@ public ToStringBuilder append(final double[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final float value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -477,8 +474,7 @@ public ToStringBuilder append(final float value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final float[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -489,8 +485,7 @@ public ToStringBuilder append(final float[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final int value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -501,8 +496,7 @@ public ToStringBuilder append(final int value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final int[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -513,8 +507,7 @@ public ToStringBuilder append(final int[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final long value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -525,8 +518,7 @@ public ToStringBuilder append(final long value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final long[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -537,8 +529,7 @@ public ToStringBuilder append(final long[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final Object obj) {
- style.append(buffer, null, obj, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, obj, null));
}
/**
@@ -549,8 +540,7 @@ public ToStringBuilder append(final Object obj) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final Object[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -561,8 +551,7 @@ public ToStringBuilder append(final Object[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final short value) {
- style.append(buffer, null, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, value));
}
/**
@@ -573,8 +562,7 @@ public ToStringBuilder append(final short value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final short[] array) {
- style.append(buffer, null, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, null, array, null));
}
/**
@@ -586,8 +574,7 @@ public ToStringBuilder append(final short[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final boolean value)
{
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -599,8 +586,7 @@ public ToStringBuilder append(final String fieldName, final
boolean value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final boolean[]
array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -621,8 +607,7 @@ public ToStringBuilder append(final String fieldName, final
boolean[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final boolean[]
array, final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -634,8 +619,7 @@ public ToStringBuilder append(final String fieldName, final
boolean[] array, fin
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final byte value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -646,8 +630,7 @@ public ToStringBuilder append(final String fieldName, final
byte value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final byte[] array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -668,8 +651,7 @@ public ToStringBuilder append(final String fieldName, final
byte[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final byte[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -681,8 +663,7 @@ public ToStringBuilder append(final String fieldName, final
byte[] array, final
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final char value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -694,8 +675,7 @@ public ToStringBuilder append(final String fieldName, final
char value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final char[] array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -716,8 +696,7 @@ public ToStringBuilder append(final String fieldName, final
char[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final char[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -729,8 +708,7 @@ public ToStringBuilder append(final String fieldName, final
char[] array, final
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final double value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -742,8 +720,7 @@ public ToStringBuilder append(final String fieldName, final
double value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final double[]
array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -764,8 +741,7 @@ public ToStringBuilder append(final String fieldName, final
double[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final double[]
array, final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -777,8 +753,7 @@ public ToStringBuilder append(final String fieldName, final
double[] array, fina
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final float value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -790,8 +765,7 @@ public ToStringBuilder append(final String fieldName, final
float value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final float[] array)
{
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -812,8 +786,7 @@ public ToStringBuilder append(final String fieldName, final
float[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final float[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -825,8 +798,7 @@ public ToStringBuilder append(final String fieldName, final
float[] array, final
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final int value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -838,8 +810,7 @@ public ToStringBuilder append(final String fieldName, final
int value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final int[] array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -860,8 +831,7 @@ public ToStringBuilder append(final String fieldName, final
int[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final int[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -873,8 +843,7 @@ public ToStringBuilder append(final String fieldName, final
int[] array, final b
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final long value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -886,8 +855,7 @@ public ToStringBuilder append(final String fieldName, final
long value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final long[] array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -908,8 +876,7 @@ public ToStringBuilder append(final String fieldName, final
long[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final long[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -921,8 +888,7 @@ public ToStringBuilder append(final String fieldName, final
long[] array, final
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final Object obj) {
- style.append(buffer, fieldName, obj, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, obj,
null));
}
/**
@@ -936,8 +902,7 @@ public ToStringBuilder append(final String fieldName, final
Object obj) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final Object obj,
final boolean fullDetail) {
- style.append(buffer, fieldName, obj, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, obj,
Boolean.valueOf(fullDetail)));
}
/**
@@ -949,8 +914,7 @@ public ToStringBuilder append(final String fieldName, final
Object obj, final bo
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final Object[]
array) {
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -971,8 +935,7 @@ public ToStringBuilder append(final String fieldName, final
Object[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final Object[]
array, final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -984,8 +947,7 @@ public ToStringBuilder append(final String fieldName, final
Object[] array, fina
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final short value) {
- style.append(buffer, fieldName, value);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, value));
}
/**
@@ -997,8 +959,7 @@ public ToStringBuilder append(final String fieldName, final
short value) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final short[] array)
{
- style.append(buffer, fieldName, array, null);
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
null));
}
/**
@@ -1019,8 +980,7 @@ public ToStringBuilder append(final String fieldName,
final short[] array) {
* @return {@code this} instance.
*/
public ToStringBuilder append(final String fieldName, final short[] array,
final boolean fullDetail) {
- style.append(buffer, fieldName, array, Boolean.valueOf(fullDetail));
- return this;
+ return appenTraversal(() -> style.append(buffer, fieldName, array,
Boolean.valueOf(fullDetail)));
}
/**
@@ -1056,7 +1016,7 @@ public ToStringBuilder appendAsObjectToString(final
Object srcObject) {
*/
public ToStringBuilder appendSuper(final String superToString) {
if (superToString != null) {
- style.appendSuper(buffer, superToString);
+ appenTraversal(() -> style.appendSuper(buffer, superToString));
}
return this;
}
@@ -1096,11 +1056,29 @@ public ToStringBuilder appendSuper(final String
superToString) {
*/
public ToStringBuilder appendToString(final String toString) {
if (toString != null) {
- style.appendToString(buffer, toString);
+ appenTraversal(() -> style.appendToString(buffer, toString));
}
return this;
}
+ /**
+ * Appends content while sharing visited objects with nested builders.
+ *
+ * @param appender the action that appends content.
+ * @return {@code this} builder.
+ */
+ ToStringBuilder appenTraversal(final Runnable appender) {
+ final boolean owner = startTraversal();
+ boolean completed = false;
+ try {
+ appender.run();
+ completed = true;
+ return this;
+ } finally {
+ ToStringStyle.endTraversal(buffer, traversal, owner, !completed);
+ }
+ }
+
/**
* Returns the String that was build as an object representation. The
* default implementation utilizes the {@link #toString()} implementation.
@@ -1114,6 +1092,15 @@ public String build() {
return toString();
}
+ /**
+ * Completes this builder's traversal and releases its visited objects.
+ *
+ * @param owner whether this builder started the active traversal.
+ */
+ void endTraversal(final boolean owner) {
+ ToStringStyle.endTraversal(buffer, traversal, owner, true);
+ }
+
/**
* Gets the {@link Object} being output.
*
@@ -1143,6 +1130,25 @@ public ToStringStyle getStyle() {
return style;
}
+ /**
+ * Gets the traversal state associated with this builder's output buffer.
+ *
+ * @return the traversal state, never {@code null}.
+ */
+ ToStringStyle.Traversal getTraversal() {
+ return traversal;
+ }
+
+ /**
+ * Starts or joins the active traversal using this builder's earlier
appends.
+ *
+ * @return whether this builder owns the active traversal.
+ */
+ boolean startTraversal() {
+ return ToStringStyle.startTraversal(traversal);
+ }
+
+
/**
* Returns the built {@code toString}.
*
@@ -1159,11 +1165,16 @@ public ToStringStyle getStyle() {
*/
@Override
public String toString() {
- if (getObject() == null) {
- getStringBuffer().append(getStyle().getNullText());
- } else {
- style.appendEnd(getStringBuffer(), getObject());
+ final boolean owner = startTraversal();
+ try {
+ if (getObject() == null) {
+ getStringBuffer().append(getStyle().getNullText());
+ } else {
+ style.appendEnd(getStringBuffer(), getObject());
+ }
+ return getStringBuffer().toString();
+ } finally {
+ endTraversal(owner);
}
- return getStringBuffer().toString();
}
}
diff --git a/src/main/java/org/apache/commons/lang3/builder/ToStringStyle.java
b/src/main/java/org/apache/commons/lang3/builder/ToStringStyle.java
index b1ed4c16d..71456ecea 100644
--- a/src/main/java/org/apache/commons/lang3/builder/ToStringStyle.java
+++ b/src/main/java/org/apache/commons/lang3/builder/ToStringStyle.java
@@ -18,12 +18,18 @@
package org.apache.commons.lang3.builder;
import java.io.Serializable;
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
import java.lang.reflect.Array;
import java.util.Collection;
+import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.Set;
+import java.util.WeakHashMap;
import org.apache.commons.lang3.ClassUtils;
import org.apache.commons.lang3.ObjectUtils;
@@ -492,6 +498,97 @@ private Object readResolve() {
}
}
+ /**
+ * Visited objects belonging to one output buffer.
+ *
+ * <p>
+ * Weak identity keys prevent visited objects that refer back to the
output buffer from keeping a weakly registered buffer alive.
+ * </p>
+ */
+ static final class Traversal {
+
+ private ReferenceQueue<Object> collected;
+
+ private Set<VisitedReference> visited;
+
+ private void add(final Object value) {
+ if (visited == null) {
+ collected = new ReferenceQueue<>();
+ visited = new HashSet<>();
+ } else {
+ removeCollected();
+ }
+ visited.add(new VisitedReference(value, collected));
+ }
+
+ private void addAll(final Traversal other) {
+ if (other.visited != null) {
+ other.removeCollected();
+ for (final VisitedReference reference : other.visited) {
+ final Object value = reference.get();
+ if (value != null) {
+ add(value);
+ }
+ }
+ }
+ }
+
+ private void clear() {
+ visited = null;
+ collected = null;
+ }
+
+ private boolean contains(final Object value) {
+ if (visited == null) {
+ return false;
+ }
+ removeCollected();
+ return visited.contains(new VisitedReference(value, null));
+ }
+
+ Set<VisitedReference> getVisited() {
+ return visited;
+ }
+
+
+ private void removeCollected() {
+ Reference<?> reference;
+ while ((reference = collected.poll()) != null) {
+ visited.remove(reference);
+ }
+ }
+ }
+
+ /**
+ * A weak identity key that does not invoke a visited object's {@code
equals()} or {@code hashCode()}.
+ */
+ private static final class VisitedReference extends WeakReference<Object> {
+
+ private final int hashCode;
+
+ private VisitedReference(final Object value, final
ReferenceQueue<Object> queue) {
+ super(value, queue);
+ hashCode = System.identityHashCode(value);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof VisitedReference)) {
+ return false;
+ }
+ final Object value = get();
+ return value != null && value == ((VisitedReference) other).get();
+ }
+
+ @Override
+ public int hashCode() {
+ return hashCode;
+ }
+ }
+
/**
* Serialization version ID.
*/
@@ -595,16 +692,96 @@ private Object readResolve() {
*/
/**
- * A per-thread set of objects already rendered in detail during the
current top-level
- * {@code reflectionToString} call. Unlike {@link #REGISTRY}, which is a
depth-first visit
- * <em>stack</em> (entries are removed when a visit completes) and
therefore only detects
- * cycles, this set is only cleared when the top-level call completes.
Styles that recurse
- * into arbitrary object graphs (see {@link RecursiveToStringStyle})
consult it so that shared
- * (acyclic) references are detailed at most once per top-level call,
keeping traversal cost
- * linear in the size of the object graph instead of exponential on
reference diamonds.
- * Identity-based for the same reason as {@link #REGISTRY}. Empty unless
such a style is in use.
+ * Traversals opened by direct style calls and retained until {@link
#appendEnd(StringBuffer, Object)}.
+ */
+ private static final ThreadLocal<Map<StringBuffer, Traversal>>
DIRECT_TRAVERSALS = new ThreadLocal<>();
+
+ /**
+ * The traversal shared by synchronous nested builders and style callbacks
in this thread.
+ */
+ private static final ThreadLocal<Traversal> TRAVERSAL_ACTIVE = new
ThreadLocal<>();
+
+ /**
+ * Weak associations between output buffers and their traversal state.
+ *
+ * <p>
+ * Builders retain their own traversal state between appends. Weak keys
and values allow abandoned builders and their visited objects to be collected
+ * without waiting for unrelated entries in {@link #REGISTRY} to
disappear. Direct style calls on a builder's buffer find the same state here.
+ * </p>
+ */
+ private static final ThreadLocal<Map<StringBuffer,
WeakReference<Traversal>>> TRAVERSALS = new ThreadLocal<>();
+
+ /**
+ * Releases completed output state unless it is shared with an active
enclosing traversal.
+ *
+ * @param buffer the completed output buffer.
+ * @param traversal its traversal state.
+ */
+ private static void clearTraversal(final StringBuffer buffer, final
Traversal traversal) {
+ if (traversal != TRAVERSAL_ACTIVE.get()) {
+ traversal.clear();
+ final Map<StringBuffer, Traversal> direct =
DIRECT_TRAVERSALS.get();
+ if (direct != null) {
+ direct.remove(buffer);
+ if (direct.isEmpty()) {
+ DIRECT_TRAVERSALS.remove();
+ }
+ }
+ }
+ }
+
+ /**
+ * Ends a direct recursive-style traversal.
+ *
+ * @param owner whether the caller started the traversal.
+ */
+ static void endStyleTraversal(final boolean owner) {
+ if (owner) {
+ TRAVERSAL_ACTIVE.remove();
+ }
+ }
+
+ /**
+ * Ends a builder traversal, retaining marks only for successful appends.
+ *
+ * @param buffer the builder's output buffer.
+ * @param traversal the builder's traversal state.
+ * @param owner whether the builder started the active traversal.
+ * @param clear whether the builder completed or an append failed.
*/
- private static final ThreadLocal<IdentityHashMap<Object, Object>> VISITED
= ThreadLocal.withInitial(IdentityHashMap::new);
+ static void endTraversal(final StringBuffer buffer, final Traversal
traversal, final boolean owner, final boolean clear) {
+ if (owner) {
+ TRAVERSAL_ACTIVE.remove();
+ }
+ if (clear) {
+ clearTraversal(buffer, traversal);
+ }
+ }
+
+ /**
+ * Finds existing traversal state without creating it.
+ *
+ * @param buffer the output buffer.
+ * @return its traversal state, or {@code null}.
+ */
+ private static Traversal findTraversal(final StringBuffer buffer) {
+ final Map<StringBuffer, WeakReference<Traversal>> traversals =
TRAVERSALS.get();
+ final WeakReference<Traversal> reference = traversals == null ? null :
traversals.get(buffer);
+ return reference == null ? null : reference.get();
+ }
+
+ /**
+ * Gets the active traversal for the current thread.
+ *
+ * @return the active traversal, or {@code null} if no traversal is active.
+ */
+ static Traversal getActiveTraversal() {
+ return TRAVERSAL_ACTIVE.get();
+ }
+
+ static ThreadLocal<Map<StringBuffer, Traversal>> getDirectTraversals() {
+ return DIRECT_TRAVERSALS;
+ }
/**
* Gets the registry of objects being traversed by the {@code
reflectionToString} methods in the current thread.
@@ -615,6 +792,29 @@ public static Map<Object, Object> getRegistry() {
return REGISTRY.get();
}
+ /**
+ * Gets traversal state for an output buffer, sharing an enclosing
traversal when necessary.
+ *
+ * @param buffer the output buffer.
+ * @return its traversal state.
+ */
+ static Traversal getTraversal(final StringBuffer buffer) {
+ Traversal traversal = findTraversal(buffer);
+ if (traversal == null) {
+ traversal = TRAVERSAL_ACTIVE.get();
+ if (traversal == null) {
+ traversal = new Traversal();
+ }
+ Map<StringBuffer, WeakReference<Traversal>> traversals =
TRAVERSALS.get();
+ if (traversals == null) {
+ traversals = new WeakHashMap<>();
+ TRAVERSALS.set(traversals);
+ }
+ traversals.put(buffer, new WeakReference<>(traversal));
+ }
+ return traversal;
+ }
+
/**
* Tests whether the registry contains the given object. Used by the
reflection methods to avoid infinite loops.
*
@@ -626,27 +826,32 @@ static boolean isRegistered(final Object value) {
}
/**
- * Tests whether the given object has already been rendered in detail
during the current
- * top-level {@code reflectionToString} call. Used by graph-recursing
styles to avoid
- * exponential re-traversal of shared (acyclic) references.
+ * Tests whether the given object has already been rendered in detail
during the active traversal.
+ *
+ * <p>
+ * Recursive styles use this to avoid exponential re-traversal of shared
(acyclic) references.
+ * </p>
*
* @param value The object to look up in the visited set.
- * @return {@code true} if the object was already visited in this
top-level call.
+ * @return {@code true} if the object was already visited in this
traversal.
*/
static boolean isVisited(final Object value) {
- return VISITED.get().containsKey(value);
+ final Traversal traversal = TRAVERSAL_ACTIVE.get();
+ return traversal != null && traversal.contains(value);
}
/**
- * Marks the given object as rendered in detail for the current top-level
- * {@code reflectionToString} call. The mark is cleared when the top-level
call completes
- * (when the visit stack in {@link #REGISTRY} empties).
+ * Marks the given object as rendered in detail for the active traversal.
+ *
+ * <p>
+ * Builders retain these marks between appends and release them on
completion, independently of {@link #REGISTRY}.
+ * </p>
*
* @param value The object to mark as visited.
*/
static void markVisited(final Object value) {
if (value != null) {
- VISITED.get().put(value, null);
+ TRAVERSAL_ACTIVE.get().add(value);
}
}
@@ -661,6 +866,34 @@ static void register(final Object value) {
}
}
+ /**
+ * Starts a direct recursive-style traversal unless a builder or style is
already rendering.
+ *
+ * @param buffer the output buffer.
+ * @return whether the caller owns the traversal and must end it.
+ */
+ static boolean startStyleTraversal(final StringBuffer buffer) {
+ return startTraversal(getTraversal(buffer));
+ }
+
+ /**
+ * Starts a traversal, sharing the active traversal when called by a
nested builder.
+ *
+ * @param traversal the output buffer's traversal state.
+ * @return whether the caller owns the traversal and must end it.
+ */
+ static boolean startTraversal(final Traversal traversal) {
+ final Traversal active = TRAVERSAL_ACTIVE.get();
+ if (active != null) {
+ if (active != traversal) {
+ active.addAll(traversal);
+ }
+ return false;
+ }
+ TRAVERSAL_ACTIVE.set(traversal);
+ return true;
+ }
+
/**
* Unregisters the given object.
*
@@ -676,8 +909,6 @@ static void unregister(final Object value) {
m.remove(value);
if (m.isEmpty()) {
REGISTRY.remove();
- // The top-level reflectionToString call is complete: clear
the visited set as well.
- VISITED.remove();
}
}
}
@@ -1470,6 +1701,10 @@ protected void appendDetail(final StringBuffer buffer,
final String fieldName, f
/**
* Appends to the {@code toString} the end of data indicator.
*
+ * <p>
+ * Completing an output buffer releases its traversal state unless it is
still shared by an active enclosing traversal.
+ * </p>
+ *
* @param buffer The {@link StringBuffer} to populate.
* @param object The {@link Object} to build a {@code toString} for.
*/
@@ -1481,6 +1716,10 @@ public void appendEnd(final StringBuffer buffer, final
Object object) {
appendContentEnd(buffer);
} finally {
unregister(object);
+ final Traversal traversal = findTraversal(buffer);
+ if (traversal != null) {
+ clearTraversal(buffer, traversal);
+ }
}
}
@@ -1651,10 +1890,23 @@ protected void appendNullText(final StringBuffer
buffer, final String fieldName)
/**
* Appends to the {@code toString} the start of data indicator.
*
+ * <p>
+ * Direct calls open traversal state for this buffer. Complete the output
with {@link #appendEnd(StringBuffer, Object)} or the owning builder's
+ * {@link ToStringBuilder#toString()} to release its traversal state.
+ * </p>
+ *
* @param buffer The {@link StringBuffer} to populate.
* @param object The {@link Object} to build a {@code toString} for.
*/
public void appendStart(final StringBuffer buffer, final Object object) {
+ if (TRAVERSAL_ACTIVE.get() == null) {
+ Map<StringBuffer, Traversal> direct = DIRECT_TRAVERSALS.get();
+ if (direct == null) {
+ direct = new WeakHashMap<>();
+ DIRECT_TRAVERSALS.set(direct);
+ }
+ direct.put(buffer, getTraversal(buffer));
+ }
if (object != null) {
appendClassName(buffer, object);
appendIdentityHashCode(buffer, object);
@@ -2349,6 +2601,7 @@ protected void setUseIdentityHashCode(final boolean
useIdentityHashCode) {
this.useIdentityHashCode = useIdentityHashCode;
}
+
/**
* Sets whether to output short or long class names.
*
diff --git
a/src/test/java/org/apache/commons/lang3/builder/ReflectionToStringBuilderTest.java
b/src/test/java/org/apache/commons/lang3/builder/ReflectionToStringBuilderTest.java
index bcbbc6901..68c731ad8 100644
---
a/src/test/java/org/apache/commons/lang3/builder/ReflectionToStringBuilderTest.java
+++
b/src/test/java/org/apache/commons/lang3/builder/ReflectionToStringBuilderTest.java
@@ -17,6 +17,11 @@
package org.apache.commons.lang3.builder;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
import org.apache.commons.lang3.AbstractLangTest;
import org.junit.jupiter.api.Test;
@@ -26,9 +31,43 @@
*/
class ReflectionToStringBuilderTest extends AbstractLangTest {
+ private static final class Graph {
+
+ final List<Object> items = new ArrayList<>();
+
+ Graph() {
+ for (int i = 0; i < 999; i++) {
+ items.add(new Object());
+ }
+ }
+ }
+
@Test
void testConstructorWithNullObject() {
assertEquals("<null>", new ReflectionToStringBuilder(null,
ToStringStyle.DEFAULT_STYLE, new StringBuffer()).toString());
}
+ @Test
+ void
testVisitedIsClearedAfterToStringWhenAnotherBuilderWasNeverStringified() {
+ final Object orphanRoot = new Graph();
+ // Constructing registers the root, but toString() is never called, so
the root is never unregistered.
+ new ReflectionToStringBuilder(orphanRoot, new
RecursiveToStringStyle());
+ try {
+ for (int i = 0; i < 10; i++) {
+ new ReflectionToStringBuilder(new Graph(), new
RecursiveToStringStyle()).toString();
+ }
+ assertNull(ToStringStyle.getActiveTraversal(), "The traversal must
end once a top-level toString() completes");
+ assertEquals(1, ToStringStyle.getRegistry().size(), "REGISTRY must
not be empty");
+ } finally {
+ ToStringStyle.unregister(orphanRoot);
+ }
+ }
+
+ @Test
+ void testVisitedIsEmptyAfterPlainToString() {
+ new ReflectionToStringBuilder(new Graph(), new
RecursiveToStringStyle()).toString();
+ assertTrue(ToStringStyle.getRegistry().isEmpty());
+ assertNull(ToStringStyle.getActiveTraversal());
+ }
+
}
diff --git
a/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderCycleTest.java
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderCycleTest.java
new file mode 100644
index 000000000..748b56762
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderCycleTest.java
@@ -0,0 +1,352 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.builder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests cycle detection and repeated-reference abbreviation across complete
outputs.
+ *
+ * <p>
+ * Detecting a reference to an object currently being rendered prevents
infinite recursion, but does not prevent expanding a shared object again after
its first
+ * rendering finishes. Recursive styles must retain visited state across
nested builders and appends so each object is detailed at most once per output.
The
+ * tests combine cycles with shared branches to exercise both requirements
through reflection builders, manual builders, and direct style calls.
+ * </p>
+ *
+ * <p>
+ * Each graph is rendered twice with the same style to detect visited state
leaking into a later output. Some cases leave an unrelated builder's root
+ * registered: completing the current output must release its state without
waiting for the entire root registry to become empty.
+ * </p>
+ */
+class ToStringBuilderCycleTest extends AbstractLangTest {
+
+ /**
+ * Supplies fields that reflection can render to distinguish a cycle edge
from a shared branch.
+ *
+ * <p>
+ * The unique {@code id} lets tests count detailed renderings
independently of identity hash codes and whitespace. The {@code next} and
{@code shared}
+ * fields are read reflectively, so they do not need accessors or a custom
{@code toString()} implementation.
+ * </p>
+ */
+ private static final class Node {
+
+ @SuppressWarnings("unused")
+ private final int id;
+ @SuppressWarnings("unused")
+ private Node next;
+ @SuppressWarnings("unused")
+ private Node shared;
+
+ /**
+ * Creates a node whose edges will be connected by the test.
+ *
+ * @param id the identifier used to count this node's detailed
renderings.
+ */
+ private Node(final int id) {
+ this.id = id;
+ }
+ }
+
+ /**
+ * Selects the API that owns the output and must release its traversal
state.
+ *
+ * <p>
+ * Reflection uses the graph itself as the root. Manual builders and
direct calls use a separate root and append the graph as a named field. This
difference
+ * accounts for the additional graph identity in a reflection output's
prefix.
+ * </p>
+ */
+ private enum RenderMode {
+
+ REFLECTION((value, style) -> complete(new
ReflectionToStringBuilder(value, style))),
+ MANUAL((value, style) -> complete(new ToStringBuilder(new Object(),
style).append("graph", value))), DIRECT(ToStringBuilderCycleTest::renderDirect);
+
+ private final BiFunction<Object, ToStringStyle, String> renderer;
+
+ /**
+ * Associates an entry point with its rendering and completion checks.
+ *
+ * @param renderer the operation that completes one output for a graph
and style.
+ */
+ RenderMode(final BiFunction<Object, ToStringStyle, String> renderer) {
+ this.renderer = renderer;
+ }
+ }
+
+ /**
+ * Supplies the recursive styles under test and the default style as a
control for container cycle detection.
+ */
+ private enum Style {
+
+ DEFAULT(() -> ToStringStyle.DEFAULT_STYLE),
RECURSIVE(RecursiveToStringStyle::new),
MULTILINE(MultilineRecursiveToStringStyle::new);
+
+ private final Supplier<ToStringStyle> factory;
+
+ /**
+ * Associates a style with a supplier so mutable recursive styles are
not shared between test cases.
+ *
+ * @param factory the style supplier; the default style can use its
shared instance.
+ */
+ Style(final Supplier<ToStringStyle> factory) {
+ this.factory = factory;
+ }
+ }
+
+ /**
+ * Renders a graph twice and checks that each completed output releases
its traversal state.
+ *
+ * <p>
+ * An optional abandoned builder leaves an unrelated root registered
throughout both renderings. This exposes cleanup that incorrectly depends on an
empty
+ * root registry. Repeating the same graph also exposes stale visited
marks that would abbreviate its first occurrence in the next output.
+ * </p>
+ *
+ * @param graph the graph to render on both passes.
+ * @param kind the style to create and reuse.
+ * @param mode the rendering entry point.
+ * @param abandoned whether to keep an unrelated abandoned builder's root
registered.
+ * @param assertions the output assertions to apply on each pass.
+ */
+ private static void assertRepeatedOutput(final Object graph, final Style
kind, final RenderMode mode, final boolean abandoned,
+ final Consumer<String> assertions) {
+ final Object orphan = new Object();
+ if (abandoned) {
+ new ReflectionToStringBuilder(orphan, new
RecursiveToStringStyle());
+ }
+ try {
+ final ToStringStyle style = kind.factory.get();
+ for (int repeat = 0; repeat < 2; repeat++) {
+ assertions.accept(mode.renderer.apply(graph, style));
+ // A completed output must release its state even when an
unrelated root remains registered.
+ assertState(orphan, abandoned);
+ }
+ } finally {
+ ToStringStyle.unregister(orphan);
+ }
+ }
+
+ /**
+ * Checks that completed outputs leave no active or direct traversal and
only the deliberately abandoned root, if any.
+ *
+ * @param orphan the unrelated registered root, ignored when {@code
abandoned} is {@code false} and may then be {@code null}.
+ * @param abandoned whether the unrelated root should remain registered.
+ */
+ private static void assertState(final Object orphan, final boolean
abandoned) {
+ assertNull(ToStringStyle.getActiveTraversal());
+ final Map<?, ?> direct = ToStringStyle.getDirectTraversals().get();
+ assertTrue(direct == null || direct.isEmpty(), "Completed direct
output must release its traversal");
+ assertEquals(abandoned ? 1 : 0, ToStringStyle.getRegistry().size());
+ if (abandoned) {
+ assertTrue(ToStringStyle.getRegistry().containsKey(orphan));
+ }
+ }
+
+ /**
+ * Completes a builder and checks that both its visited state and root
registration have been released.
+ *
+ * <p>
+ * The final unregister operation prevents a failed assertion from leaving
the test root behind; the assertions run before that fallback cleanup.
+ * </p>
+ *
+ * @param builder the builder whose output is ready to complete.
+ * @return the completed output.
+ */
+ private static String complete(final ToStringBuilder builder) {
+ try {
+ final String output = builder.toString();
+ assertNull(builder.getTraversal().getVisited());
+
assertFalse(ToStringStyle.getRegistry().containsKey(builder.getObject()));
+ return output;
+ } finally {
+ ToStringStyle.unregister(builder.getObject());
+ }
+ }
+
+ /**
+ * Supplies long cycles that close at the root or at an interior node
through each rendering entry point.
+ *
+ * @return combinations of both recursive styles, all entry points, depths
of 32 and 64, cycle targets, and unrelated root registration.
+ */
+ static Stream<Arguments> longCycles() {
+ final List<Arguments> arguments = new ArrayList<>();
+ for (final Style style : new Style[] { Style.RECURSIVE,
Style.MULTILINE }) {
+ for (final RenderMode mode : RenderMode.values()) {
+ for (final int depth : new int[] { 32, 64 }) {
+ for (final int backEdge : new int[] { 0, depth / 2 }) {
+ for (final boolean abandoned : new boolean[] { false,
true }) {
+ arguments.add(Arguments.of(style, mode, depth,
backEdge, abandoned));
+ }
+ }
+ }
+ }
+ }
+ return arguments.stream();
+ }
+
+ /**
+ * Supplies mixed container cycles for both recursive styles and the
default style.
+ *
+ * @return combinations of styles, rendering entry points, and unrelated
root registration.
+ */
+ static Stream<Arguments> mixedCycles() {
+ final List<Arguments> arguments = new ArrayList<>();
+ for (final Style style : Style.values()) {
+ for (final RenderMode mode : RenderMode.values()) {
+ for (final boolean abandoned : new boolean[] { false, true }) {
+ arguments.add(Arguments.of(style, mode, abandoned));
+ }
+ }
+ }
+ return arguments.stream();
+ }
+
+ /**
+ * Renders a graph using the direct style lifecycle and checks that {@code
appendEnd()} unregisters its root.
+ *
+ * <p>
+ * These calls have no builder to own the traversal. They exercise the
state associated with the output buffer between {@code appendStart()},
+ * {@code append()}, and {@code appendEnd()}.
+ * </p>
+ *
+ * @param graph the graph to append as a field of a separate root.
+ * @param style the style that manages the direct traversal.
+ * @return the completed output.
+ */
+ private static String renderDirect(final Object graph, final ToStringStyle
style) {
+ final Object root = new Object();
+ final StringBuffer buffer = new StringBuffer();
+ try {
+ style.appendStart(buffer, root);
+ style.append(buffer, "graph", graph, Boolean.TRUE);
+ style.appendEnd(buffer, root);
+ assertFalse(ToStringStyle.getRegistry().containsKey(root));
+ return buffer.toString();
+ } finally {
+ ToStringStyle.unregister(root);
+ }
+ }
+
+ /**
+ * Checks for leaked state before clearing root registrations and active
and direct traversal state for subsequent tests.
+ */
+ @AfterEach
+ void tearDown() {
+ try {
+ assertState(null, false);
+ } finally {
+ // Keep a failed cycle assertion from contaminating subsequent
tests on this thread.
+ new
ArrayList<>(ToStringStyle.getRegistry().keySet()).forEach(ToStringStyle::unregister);
+ ToStringStyle.endStyleTraversal(true);
+ ToStringStyle.getDirectTraversals().remove();
+ }
+ }
+
+ /**
+ * Tests a long chain whose final edge closes a cycle and whose nodes all
share a reference to the middle node.
+ *
+ * <p>
+ * Each identifier must occur in detail exactly once. Cycle-closing edges
and subsequent shared references must be abbreviated; merely terminating
without a
+ * stack overflow would not detect redundant expansion of the shared
branch. Both renderings must satisfy the same assertions and release their
traversal
+ * state, including when an unrelated root remains registered.
+ * </p>
+ *
+ * @param style the recursive style to exercise.
+ * @param mode the rendering entry point.
+ * @param depth the number of distinct nodes in the chain.
+ * @param backEdge the index targeted by the final node's {@code next}
reference.
+ * @param abandoned whether an unrelated abandoned builder's root remains
registered.
+ */
+ @ParameterizedTest(name = "{0}, {1}, depth={2}, backEdge={3},
abandoned={4}")
+ @MethodSource("longCycles")
+ void testLongCycleWithSharedBranches(final Style style, final RenderMode
mode, final int depth, final int backEdge, final boolean abandoned) {
+ final Node[] nodes = new Node[depth];
+ for (int i = 0; i < depth; i++) {
+ nodes[i] = new Node(i);
+ }
+ for (int i = 0; i < depth - 1; i++) {
+ nodes[i].next = nodes[i + 1];
+ }
+ nodes[depth - 1].next = nodes[backEdge];
+ for (final Node node : nodes) {
+ node.shared = nodes[depth / 2];
+ }
+ assertRepeatedOutput(nodes[0], style, mode, abandoned, output -> {
+ // Every node is detailed once. Closing the cycle and following
shared branches must use abbreviated references.
+ for (int i = 0; i < depth; i++) {
+ assertEquals(1, StringUtils.countMatches(output, "id=" + i +
","), "Node " + i + " must be detailed exactly once");
+ }
+ assertTrue(output.contains("next=" +
ObjectUtils.identityToString(nodes[backEdge])));
+ assertTrue(output.length() < 1_000_000, "Cycle output must remain
bounded");
+ });
+ }
+
+ /**
+ * Tests an {@code array -> list -> map -> array} cycle with a child
shared by the list and map.
+ *
+ * <p>
+ * The cycle must close with one reference to the array's identity;
reflection adds a second occurrence in the root prefix. The shared child's
identity must
+ * occur twice. Recursive styles must detail that child only once, while
the default style uses its inherited {@code Object.toString()} on both
occurrences.
+ * These assertions check that cycle detection and visited state survive
transitions between container types.
+ * </p>
+ *
+ * @param style the style to exercise, including the default style as
a control.
+ * @param mode the rendering entry point.
+ * @param abandoned whether an unrelated abandoned builder's root remains
registered.
+ */
+ @ParameterizedTest(name = "{0}, {1}, abandoned={2}")
+ @MethodSource("mixedCycles")
+ void testMixedArrayCollectionMapCycle(final Style style, final RenderMode
mode, final boolean abandoned) {
+ final Object[] graph = new Object[1];
+ final List<Object> list = new ArrayList<>();
+ final Map<String, Object> map = new LinkedHashMap<>();
+ final Node shared = new Node(999);
+ graph[0] = list;
+ list.add(map);
+ list.add(shared);
+ map.put("back", graph);
+ map.put("shared", shared);
+ assertRepeatedOutput(graph, style, mode, abandoned, output -> {
+ // The array -> list -> map -> array cycle closes with an identity
reference, not another expansion.
+ final String graphIdentity = ObjectUtils.identityToString(graph);
+ assertEquals(mode == RenderMode.REFLECTION ? 2 : 1,
StringUtils.countMatches(output, graphIdentity));
+ final String childIdentity = ObjectUtils.identityToString(shared);
+ assertEquals(2, StringUtils.countMatches(output, childIdentity));
+ // Recursive styles detail the shared child once; the default
style uses its own Object.toString() both times.
+ assertEquals(style == Style.DEFAULT ? 0 : 1,
StringUtils.countMatches(output, childIdentity + "["));
+ assertTrue(output.length() < 10_000, "Mixed cycle output must
remain bounded");
+ });
+ }
+}
diff --git
a/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalConcurrencyTest.java
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalConcurrencyTest.java
new file mode 100644
index 000000000..9c5e6339a
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalConcurrencyTest.java
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.builder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+/**
+ * Tests isolation of visited state between threads and between successive
outputs on the same thread.
+ *
+ * <p>
+ * Two workers render the same object concurrently. Each output must detail
its first occurrence and abbreviate its second occurrence, regardless of
whether the
+ * other worker has already visited that object. A barrier inside the
rendering callback forces the traversals to overlap instead of relying on thread
+ * scheduling to expose shared state.
+ * </p>
+ *
+ * <p>
+ * The workers repeat the operation with their own style instances to expose
state left on reused threads. Only one worker keeps an unrelated root
registered,
+ * so the assertions also check that root registration and traversal cleanup
remain local to each thread.
+ * </p>
+ */
+class ToStringBuilderTraversalConcurrencyTest extends AbstractLangTest {
+
+ /**
+ * Waits for both workers to reach the shared object's rendering callback.
+ *
+ * <p>
+ * A timeout turns a missing callback into a bounded test failure. For
example, state shared across threads could make one worker abbreviate the object
+ * immediately and skip the callback, leaving the other worker waiting.
Interruptions preserve the interrupt flag for executor shutdown.
+ * </p>
+ *
+ * @param barrier the barrier shared by the two workers.
+ * @throws IllegalStateException if synchronization is interrupted, times
out, or the barrier is broken.
+ */
+ private static void awaitOtherTraversal(final CyclicBarrier barrier) {
+ try {
+ barrier.await(10, TimeUnit.SECONDS);
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(ex);
+ } catch (final BrokenBarrierException | TimeoutException ex) {
+ throw new IllegalStateException(ex);
+ }
+ }
+
+ /**
+ * Creates a worker's style with a synchronization point after the shared
object is marked visited and before it is rendered reflectively.
+ *
+ * <p>
+ * Each worker needs its own style because multiline indentation is
mutable. The barrier coordinates traversal state without introducing a separate
race
+ * through shared formatting state.
+ * </p>
+ *
+ * @param multiline whether to use the multiline recursive style.
+ * @param shared the object whose rendering must overlap on both
workers.
+ * @param barrier the barrier shared by the workers.
+ * @return a style that pauses when rendering the shared object in detail.
+ */
+ private static RecursiveToStringStyle createStyle(final boolean multiline,
final Object shared, final CyclicBarrier barrier) {
+ if (multiline) {
+ return new MultilineRecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>
+ * Waits for the other worker before rendering the shared
object so both traversals are active at the same time.
+ * </p>
+ */
+ @Override
+ public void appendDetail(final StringBuffer buffer, final
String fieldName, final Object value) {
+ if (value == shared) {
+ awaitOtherTraversal(barrier);
+ }
+ super.appendDetail(buffer, fieldName, value);
+ }
+ };
+ }
+ return new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * {@inheritDoc}
+ *
+ * <p>
+ * Waits for the other worker before rendering the shared object
so both traversals are active at the same time.
+ * </p>
+ */
+ @Override
+ public void appendDetail(final StringBuffer buffer, final String
fieldName, final Object value) {
+ if (value == shared) {
+ awaitOtherTraversal(barrier);
+ }
+ super.appendDetail(buffer, fieldName, value);
+ }
+ };
+ }
+
+ /**
+ * Renders 100 outputs on one worker and checks their contents and
thread-local cleanup.
+ *
+ * <p>
+ * Each output contains the shared object's identity twice, with a
detailed first occurrence and an abbreviated second occurrence. Repeating the
assertions
+ * on the same thread detects visited marks leaking from a previous
output. The optional unrelated root must survive each completed output, then be
removed
+ * when this worker finishes.
+ * </p>
+ *
+ * @param multiline whether to use the multiline recursive style.
+ * @param shared the object rendered twice in each output and shared
with the other worker.
+ * @param barrier the synchronization point used for each output's first
occurrence.
+ * @param abandoned whether to keep an unrelated abandoned builder's root
registered on this worker.
+ */
+ private static void renderOnWorker(final boolean multiline, final Object
shared, final CyclicBarrier barrier, final boolean abandoned) {
+ final Object orphan = new Object();
+ if (abandoned) {
+ new ReflectionToStringBuilder(orphan, new
RecursiveToStringStyle());
+ }
+ try {
+ // Each worker reuses its own style; multiline indentation is
mutable and must not be shared between threads.
+ final RecursiveToStringStyle style = createStyle(multiline,
shared, barrier);
+ final String identity = ObjectUtils.identityToString(shared);
+ for (int i = 0; i < 100; i++) {
+ final String output = new ToStringBuilder(new Object[0],
style).append("first", shared).append("second", shared).toString();
+ assertEquals(2, StringUtils.countMatches(output, identity));
+ assertEquals(1, StringUtils.countMatches(output, identity +
"["));
+ assertTrue(output.contains("first=" + identity + "["));
+ assertNull(ToStringStyle.getActiveTraversal());
+ final Map<?, ?> direct =
ToStringStyle.getDirectTraversals().get();
+ assertTrue(direct == null || direct.isEmpty());
+ assertEquals(abandoned ? 1 : 0,
ToStringStyle.getRegistry().size());
+ if (abandoned) {
+
assertTrue(ToStringStyle.getRegistry().containsKey(orphan));
+ }
+ }
+ } finally {
+ ToStringStyle.unregister(orphan);
+ }
+ assertTrue(ToStringStyle.getRegistry().isEmpty(), "A reused worker
must not retain completed roots");
+ }
+
+ /**
+ * Tests that two overlapping traversals can render the same object
independently on reused worker threads.
+ *
+ * <p>
+ * One worker keeps an unrelated root registered and the other does not.
Both must produce a fresh detailed first occurrence in every output. Bounded
waits
+ * and executor shutdown ensure that a traversal regression cannot leave
the test waiting indefinitely or retain worker threads.
+ * </p>
+ *
+ * @param multiline whether to exercise the multiline recursive style
instead of the single-line recursive style.
+ * @throws Exception if a worker fails, a wait times out, or the test
thread is interrupted.
+ */
+ @ParameterizedTest(name = "multiline={0}")
+ @ValueSource(booleans = { false, true })
+ void testOverlappingTraversalsOnReusedWorkers(final boolean multiline)
throws Exception {
+ final Object shared = new Object();
+ final CyclicBarrier barrier = new CyclicBarrier(2);
+ final ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ // Both threads pause after marking the same object visited,
before either completes its nested rendering.
+ final Future<?> withOrphan = pool.submit(() ->
renderOnWorker(multiline, shared, barrier, true));
+ final Future<?> withoutOrphan = pool.submit(() ->
renderOnWorker(multiline, shared, barrier, false));
+ withOrphan.get(30, TimeUnit.SECONDS);
+ withoutOrphan.get(30, TimeUnit.SECONDS);
+ } finally {
+ pool.shutdownNow();
+ assertTrue(pool.awaitTermination(10, TimeUnit.SECONDS), "Worker
threads must terminate even if an assertion fails");
+ }
+ }
+}
diff --git
a/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalMemoryTest.java
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalMemoryTest.java
new file mode 100644
index 000000000..08cce30f5
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalMemoryTest.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.builder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.apache.commons.lang3.SystemUtils;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Tests that abandoned direct outputs remain collectable when a visited
object refers back to their output buffer.
+ *
+ * <p>
+ * Direct style calls retain traversal state between {@code appendStart()} and
{@code appendEnd()} using weak buffer keys. Weak keys alone cannot release an
+ * abandoned buffer if the associated traversal strongly retains a visited
object that points back to that buffer. Weak visited references must break this
+ * retention path even when the caller never invokes {@code appendEnd()}.
+ * </p>
+ *
+ * <p>
+ * A child JVM allocates more buffer capacity than its bounded heap can
retain. Successful completion demonstrates that abandoned outputs can be
collected under
+ * allocation pressure, without depending on explicit garbage collection or
risking the main test runner's heap.
+ * </p>
+ */
+class ToStringBuilderTraversalMemoryTest extends AbstractLangTest {
+
+ /**
+ * Supplies a standalone entry point for the child JVM's allocation
workload.
+ *
+ * <p>
+ * All workload helpers are nested here so the child needs only the
project's main and test classes, without invoking JUnit. The parent test
provides the
+ * heap limit and checks both the exit status and a completion marker.
+ * </p>
+ */
+ public static final class MemoryProbe {
+
+ /**
+ * Supplies a reflectively visited field that strongly references the
output buffer.
+ *
+ * <p>
+ * Retaining this object in a traversal's visited set would also
retain the buffer used as the direct traversal map's weak key.
+ * </p>
+ */
+ private static final class BufferLink {
+
+ @SuppressWarnings("unused")
+ private final StringBuffer buffer;
+
+ /**
+ * Creates the back reference that exposes strong retention by a
visited set.
+ *
+ * @param buffer the output buffer that must become collectable
after the iteration.
+ */
+ private BufferLink(final StringBuffer buffer) {
+ this.buffer = buffer;
+ }
+ }
+
+ /**
+ * Renders the back reference while isolating traversal retention from
root registration and JDK implementation fields.
+ */
+ private static final class DirectStyle extends RecursiveToStringStyle {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Disables the prefixes that register the root, so an omitted
{@code appendEnd()} tests only abandoned traversal state.
+ */
+ private DirectStyle() {
+ // Avoid registering an abandoned root: this test concerns
visited objects, not the older root registry behavior.
+ setUseClassName(false);
+ setUseIdentityHashCode(false);
+ }
+
+ /**
+ * Excludes buffer internals from reflection while allowing {@link
BufferLink} to be visited.
+ *
+ * <p>
+ * The buffer is rendered using its own {@code toString()},
avoiding reflective access to JDK implementation fields. The link still holds
the strong
+ * back reference needed to expose a retention regression.
+ * </p>
+ *
+ * @param type the class considered for recursive reflection.
+ * @return {@code false} for {@link StringBuffer}, otherwise the
superclass decision.
+ */
+ @Override
+ protected boolean accept(final Class<?> type) {
+ return type != StringBuffer.class && super.accept(type);
+ }
+ }
+
+ /**
+ * Creates 20,000 abandoned outputs whose total buffer capacity
exceeds the child JVM's 32 MB heap.
+ *
+ * <p>
+ * Every iteration opens a direct traversal, visits an object that
points back to its buffer, and deliberately omits {@code appendEnd()}. The root
+ * registry must remain empty so it cannot explain retained buffers.
With weak visited references, allocation pressure allows old outputs to be
+ * collected and the workload prints its completion marker; strong
retention exhausts the bounded heap before completion.
+ * </p>
+ *
+ * @param args unused.
+ */
+ public static void main(final String[] args) {
+ final DirectStyle style = new DirectStyle();
+ for (int i = 0; i < 20_000; i++) {
+ final StringBuffer buffer = new StringBuffer(4096);
+ style.appendStart(buffer, new Object());
+ style.append(buffer, "link", new BufferLink(buffer),
Boolean.TRUE);
+ // Deliberately omit appendEnd(). A visited object refers back
to the weakly registered output buffer.
+ if (!ToStringStyle.getRegistry().isEmpty()) {
+ throw new AssertionError("The root registry must not
retain these outputs");
+ }
+ }
+ System.out.println("Completed 20000 abandoned outputs");
+ }
+ }
+
+ /**
+ * Tests that abandoned buffer back references do not prevent the
allocation workload from completing within a 32 MB heap.
+ *
+ * <p>
+ * The child uses the current Java runtime and the main and test class
locations. Exit status zero and the completion marker establish that the entire
+ * workload ran successfully. A timeout and forced cleanup bound failures,
and captured child output makes memory exhaustion diagnosable.
+ * </p>
+ *
+ * @param tempDir the directory for the child's combined standard output
and error log.
+ * @throws Exception if class locations cannot be resolved, the process or
log cannot be accessed, or waiting is interrupted.
+ */
+ @Test
+ void testAbandonedOutputWithBufferBackReferenceIsCollectable(@TempDir
final Path tempDir) throws Exception {
+ final Path java = Paths.get(System.getProperty("java.home"), "bin",
SystemUtils.IS_OS_WINDOWS ? "java.exe" : "java");
+ final String classPath =
Paths.get(MemoryProbe.class.getProtectionDomain().getCodeSource().getLocation().toURI())
+ File.pathSeparator
+ +
Paths.get(ToStringBuilder.class.getProtectionDomain().getCodeSource().getLocation().toURI());
+ final Path log = tempDir.resolve("memory-probe.log");
+ final Process process = new ProcessBuilder(java.toString(), "-Xmx32m",
"-cp", classPath, MemoryProbe.class.getName()).redirectErrorStream(true)
+ .redirectOutput(log.toFile()).start();
+ try {
+ assertTrue(process.waitFor(60, TimeUnit.SECONDS), "The memory
probe must finish within its time limit");
+ final String output = new String(Files.readAllBytes(log),
StandardCharsets.UTF_8);
+ assertEquals(0, process.exitValue(), output);
+ assertTrue(output.contains("Completed 20000 abandoned outputs"),
output);
+ } finally {
+ process.destroyForcibly();
+ assertTrue(process.waitFor(10, TimeUnit.SECONDS), "The child JVM
must terminate even if the test fails");
+ }
+ }
+}
diff --git
a/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalTest.java
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalTest.java
new file mode 100644
index 000000000..df8300919
--- /dev/null
+++
b/src/test/java/org/apache/commons/lang3/builder/ToStringBuilderTraversalTest.java
@@ -0,0 +1,632 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.commons.lang3.builder;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.ref.Reference;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.AbstractLangTest;
+import org.apache.commons.lang3.ObjectUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests visited-object ownership across builder traversals.
+ */
+class ToStringBuilderTraversalTest extends AbstractLangTest {
+
+ private static final class FailingStyle extends RecursiveToStringStyle {
+
+ private static final long serialVersionUID = 1L;
+ private final Object failure;
+
+ FailingStyle(final Object failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ public void appendDetail(final StringBuffer buffer, final String
fieldName, final Object value) {
+ if (value == failure) {
+ throw new IllegalStateException("Cannot render field");
+ }
+ super.appendDetail(buffer, fieldName, value);
+ }
+ }
+
+ private static final class IdentityOnly {
+
+ @Override
+ public boolean equals(final Object other) {
+ throw new AssertionError("Traversal must not call equals()");
+ }
+
+ @Override
+ public int hashCode() {
+ throw new AssertionError("Traversal must not call hashCode()");
+ }
+ }
+
+ static Stream<BiConsumer<ToStringBuilder, Object[]>> appenders() {
+ // @formatter:off
+ return Stream.of(
+ (builder, array) -> builder.append((Object) array),
+ (builder, array) -> builder.append(array),
+ (builder, array) -> builder.append("value", (Object) array),
+ (builder, array) -> builder.append("value", array),
+ (builder, array) -> builder.append("value", (Object) array,
true),
+ (builder, array) -> builder.append("value", array, true));
+ // @formatter:on
+ }
+
+ private static void assertNoVisitedObjects() {
+ assertNull(ToStringStyle.getActiveTraversal(), "The traversal must
have ended");
+ final Map<?, ?> direct = ToStringStyle.getDirectTraversals().get();
+ assertTrue(direct == null || direct.isEmpty(), "Completed direct style
output must release its traversal");
+ }
+
+ private static Set<?> builderVisited(final ToStringBuilder builder) {
+ return builder.getTraversal().getVisited();
+ }
+
+ private static String identityDetailPrefix(final Object object) {
+ return ObjectUtils.identityToString(object) + "[";
+ }
+
+ static Stream<Consumer<ToStringBuilder>> primitiveAppenders() {
+ // @formatter:off
+ return Stream.of(
+ builder -> builder.append(true),
+ builder -> builder.append("value", true),
+ builder -> builder.append(new boolean[] {true}),
+ builder -> builder.append("value", new boolean[] {true}),
+ builder -> builder.append("value", new boolean[] {true}, true),
+ builder -> builder.append((byte) 1),
+ builder -> builder.append("value", (byte) 1),
+ builder -> builder.append(new byte[] {1}),
+ builder -> builder.append("value", new byte[] {1}),
+ builder -> builder.append("value", new byte[] {1}, true),
+ builder -> builder.append('a'),
+ builder -> builder.append("value", 'a'),
+ builder -> builder.append(new char[] {'a'}),
+ builder -> builder.append("value", new char[] {'a'}),
+ builder -> builder.append("value", new char[] {'a'}, true),
+ builder -> builder.append(1.0),
+ builder -> builder.append("value", 1.0),
+ builder -> builder.append(new double[] {1}),
+ builder -> builder.append("value", new double[] {1}),
+ builder -> builder.append("value", new double[] {1}, true),
+ builder -> builder.append(1.0f),
+ builder -> builder.append("value", 1.0f),
+ builder -> builder.append(new float[] {1}),
+ builder -> builder.append("value", new float[] {1}),
+ builder -> builder.append("value", new float[] {1}, true),
+ builder -> builder.append(1),
+ builder -> builder.append("value", 1),
+ builder -> builder.append(new int[] {1}),
+ builder -> builder.append("value", new int[] {1}),
+ builder -> builder.append("value", new int[] {1}, true),
+ builder -> builder.append(1L),
+ builder -> builder.append("value", 1L),
+ builder -> builder.append(new long[] {1}),
+ builder -> builder.append("value", new long[] {1}),
+ builder -> builder.append("value", new long[] {1}, true),
+ builder -> builder.append((short) 1),
+ builder -> builder.append("value", (short) 1),
+ builder -> builder.append(new short[] {1}),
+ builder -> builder.append("value", new short[] {1}),
+ builder -> builder.append("value", new short[] {1}, true));
+ // @formatter:on
+ }
+
+ static Stream<ToStringStyle> styles() {
+ return Stream.of(new RecursiveToStringStyle(), new
MultilineRecursiveToStringStyle());
+ }
+
+ private final Object orphan = new Object();
+
+ @BeforeEach
+ void setUp() {
+ new ReflectionToStringBuilder(orphan, new RecursiveToStringStyle());
+ }
+
+ @AfterEach
+ void tearDown() {
+ ToStringStyle.unregister(orphan);
+ try {
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.endStyleTraversal(true);
+ ToStringStyle.getDirectTraversals().remove();
+ }
+ }
+
+ @Test
+ void testAppendFailureReleasesTraversal() {
+ final Object failure = new Object();
+ final ToStringBuilder builder = new ToStringBuilder(new Object(), new
FailingStyle(failure));
+ try {
+ assertThrows(IllegalStateException.class, () -> builder.append(new
Object[] { new Object(), failure }));
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder), "A failed append must release
the builder's visited objects");
+ builder.toString();
+ assertNull(builderVisited(builder));
+ } finally {
+ ToStringStyle.unregister(builder.getObject());
+ }
+ new ReflectionToStringBuilder(new Object[] { new Object() }, new
RecursiveToStringStyle()).toString();
+ assertNoVisitedObjects();
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testAppendsBeforeReflectionToString(final ToStringStyle style) {
+ final Object shared = new Object();
+ for (int i = 0; i < 10; i++) {
+ final ReflectionToStringBuilder builder = new
ReflectionToStringBuilder(new Object[] { shared, new Object() }, style);
+ builder.append("extra", shared);
+ assertNoVisitedObjects();
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ }
+ assertEquals(1, ToStringStyle.getRegistry().size());
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testBuilderAndDirectStyleAppendsShareVisitedObjects(final
ToStringStyle style) {
+ final Object shared = new Object();
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
style).append(shared);
+ style.append(builder.getStringBuffer(), "direct", shared,
Boolean.TRUE);
+ final String result = builder.append(shared).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(shared)));
+ assertNull(builderVisited(builder));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testCollectedVisitedReferencesAreRemoved() {
+ final ToStringBuilder builder = new ToStringBuilder(new Object(), new
RecursiveToStringStyle());
+ builder.append(new Object());
+ final Set<?> visited = builderVisited(builder);
+ final Reference<?> reference = (Reference<?>)
visited.iterator().next();
+ // Exercise queue cleanup deterministically without depending on the
garbage collector's scheduling.
+ reference.clear();
+ reference.enqueue();
+ final Object next = new Object();
+ builder.append(next);
+ assertEquals(1, visited.size());
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(next)));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testCompletionFailureReleasesTraversal() {
+ final Object root = new Object();
+ final ToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void appendEnd(final StringBuffer buffer, final Object
object) {
+ if (object == root) {
+ throw new IllegalStateException("Cannot complete output");
+ }
+ super.appendEnd(buffer, object);
+ }
+ };
+ final ToStringBuilder builder = new ToStringBuilder(root, style);
+ try {
+ builder.append(new Object[] { new Object() });
+ assertThrows(IllegalStateException.class, builder::toString);
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ } finally {
+ ToStringStyle.unregister(builder.getObject());
+ }
+ }
+
+ @Test
+ void testConstructorCallbackSharesVisitedObjects() {
+ final Object root = new Object();
+ final Object shared = new Object();
+ final ToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void appendStart(final StringBuffer buffer, final Object
object) {
+ super.appendStart(buffer, object);
+ if (object == root) {
+ append(buffer, "prefix", shared, Boolean.TRUE);
+ }
+ }
+ };
+ final ToStringBuilder builder = new ToStringBuilder(root, style);
+ assertNoVisitedObjects();
+ assertEquals(1,
StringUtils.countMatches(builder.append(shared).toString(),
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testConstructorFailureReleasesTraversal() {
+ final Object root = new Object();
+ final ToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void appendStart(final StringBuffer buffer, final Object
object) {
+ super.appendStart(buffer, object);
+ if (object == root) {
+ append(buffer, "prefix", new Object(), Boolean.TRUE);
+ throw new IllegalStateException("Cannot start output");
+ }
+ }
+ };
+ try {
+ assertThrows(IllegalStateException.class, () -> new
ToStringBuilder(root, style));
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.unregister(root);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("primitiveAppenders")
+ void testCustomPrimitiveFormattingSharesVisitedObjects(final
Consumer<ToStringBuilder> appender) {
+ final Object shared = new Object();
+ final ToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void appendFieldStart(final StringBuffer buffer, final
String fieldName) {
+ super.appendFieldStart(buffer, fieldName);
+ appendInternal(buffer, fieldName, shared, true);
+ }
+ };
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
style).append(shared);
+ appender.accept(builder);
+ assertNoVisitedObjects();
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(shared)));
+ }
+
+ @Test
+ void testCustomSuperAndToStringFormattingShareVisitedObjects() {
+ final Object shared = new Object();
+ final ToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public void appendSuper(final StringBuffer buffer, final String
superToString) {
+ appendInternal(buffer, null, shared, true);
+ }
+
+ @Override
+ public void appendToString(final StringBuffer buffer, final String
toString) {
+ appendInternal(buffer, null, shared, true);
+ }
+ };
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
style).append(shared);
+ builder.appendSuper("super").appendToString("delegate");
+ assertNoVisitedObjects();
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(shared)));
+ }
+
+ @Test
+ void testDirectArrayAppendKeepsTraversalActiveBetweenItems() {
+ final RecursiveToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void appendDetail(final StringBuffer buffer, final
String fieldName, final int index, final Object value) {
+ assertNotNull(ToStringStyle.getActiveTraversal(), "The array
traversal must survive between items, even without appendStart()");
+ super.appendDetail(buffer, fieldName, index, value);
+ }
+ };
+ final Object shared = new Object();
+ final StringBuffer buffer = new StringBuffer();
+ style.append(buffer, "array", new Object[] { shared, shared },
Boolean.TRUE);
+ assertEquals(1, StringUtils.countMatches(buffer,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testDirectStyleAppendThenBuilderCompletion(final ToStringStyle style)
{
+ for (int i = 0; i < 100; i++) {
+ final Object shared = new Object();
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
style);
+ style.append(builder.getStringBuffer(), "direct", shared,
Boolean.TRUE);
+ final String result = builder.append(shared).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(shared)));
+ assertNull(builderVisited(builder));
+ assertNoVisitedObjects();
+ }
+ assertEquals(1, ToStringStyle.getRegistry().size());
+ }
+
+ @Test
+ void testDirectStyleAppendThenBuilderCompletionWithoutOrphan() {
+ ToStringStyle.unregister(orphan);
+ for (int i = 0; i < 100; i++) {
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
new RecursiveToStringStyle());
+ builder.getStyle().append(builder.getStringBuffer(), "direct", new
Object(), Boolean.TRUE);
+ builder.toString();
+ assertNull(builderVisited(builder));
+ assertNoVisitedObjects();
+ }
+ assertTrue(ToStringStyle.getRegistry().isEmpty());
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testDirectStyleCallsKeepSharedReferences(final ToStringStyle style) {
+ final Object shared = new Object();
+ Object graph = shared;
+ for (int i = 0; i < 20; i++) {
+ graph = new Object[] { graph, graph };
+ }
+ final StringBuffer buffer = new StringBuffer();
+ final Object root = new Object();
+ try {
+ style.appendStart(buffer, root);
+ style.append(buffer, "graph", new Object[] { graph, graph },
Boolean.TRUE);
+ style.appendEnd(buffer, root);
+ assertEquals(1, StringUtils.countMatches(buffer,
identityDetailPrefix(shared)));
+ assertTrue(buffer.length() < 10_000, "Shared references must not
be expanded repeatedly");
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.unregister(root);
+ }
+ }
+
+ @Test
+ void testDirectStyleCompletionReleasesBuilderState() {
+ final ToStringBuilder builder = new ToStringBuilder(new Object(), new
RecursiveToStringStyle()).append(new Object());
+ builder.getStyle().appendEnd(builder.getStringBuffer(),
builder.getObject());
+ assertNull(builderVisited(builder));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testDirectStyleStateSurvivesFailedBuilder() {
+ final Object shared = new Object();
+ final Object root = new Object();
+ final Object failure = new Object();
+ final Object[] failedRoot = { new Object(), failure };
+ final RecursiveToStringStyle style = new RecursiveToStringStyle();
+ final StringBuffer buffer = new StringBuffer();
+ try {
+ style.appendStart(buffer, root);
+ style.append(buffer, "first", shared, Boolean.TRUE);
+ assertThrows(IllegalStateException.class, () -> new
ReflectionToStringBuilder(failedRoot, new FailingStyle(failure)).toString());
+ style.append(buffer, "second", shared, Boolean.TRUE);
+ style.appendEnd(buffer, root);
+ assertEquals(1, StringUtils.countMatches(buffer,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.unregister(root);
+ ToStringStyle.unregister(failedRoot);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testDirectStyleStateSurvivesIndependentBuilder(final ToStringStyle
style) {
+ final Object shared = new Object();
+ final Object root = new Object();
+ final StringBuffer buffer = new StringBuffer();
+ try {
+ style.appendStart(buffer, root);
+ style.append(buffer, "first", shared, Boolean.TRUE);
+ new ReflectionToStringBuilder(new Object[] { new Object() }, new
RecursiveToStringStyle()).toString();
+ style.append(buffer, "second", shared, Boolean.TRUE);
+ style.appendEnd(buffer, root);
+ assertEquals(1, StringUtils.countMatches(buffer,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.unregister(root);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testInterleavedBuildersKeepIndependentVisitedObjects(final
ToStringStyle style) {
+ final Object shared = new Object();
+ final ToStringBuilder first = new ToStringBuilder(new Object(),
style).append(shared);
+ final ToStringBuilder second = new ToStringBuilder(new Object(),
style).append(shared);
+ assertEquals(1,
StringUtils.countMatches(first.append(shared).toString(),
identityDetailPrefix(shared)));
+ assertEquals(1,
StringUtils.countMatches(second.append(shared).toString(),
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testInterleavedDirectStyleOutputs(final ToStringStyle style) {
+ final Object shared = new Object();
+ final Object firstRoot = new Object();
+ final Object secondRoot = new Object();
+ final StringBuffer first = new StringBuffer();
+ final StringBuffer second = new StringBuffer();
+ try {
+ style.appendStart(first, firstRoot);
+ style.append(first, "first", shared, Boolean.TRUE);
+ style.appendStart(second, secondRoot);
+ style.append(second, "first", shared, Boolean.TRUE);
+ style.append(first, "second", shared, Boolean.TRUE);
+ style.appendEnd(first, firstRoot);
+ style.append(second, "second", shared, Boolean.TRUE);
+ style.appendEnd(second, secondRoot);
+ assertEquals(1, StringUtils.countMatches(first,
identityDetailPrefix(shared)));
+ assertEquals(1, StringUtils.countMatches(second,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ } finally {
+ ToStringStyle.unregister(firstRoot);
+ ToStringStyle.unregister(secondRoot);
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("appenders")
+ void testManualAppendsReleaseTraversal(final BiConsumer<ToStringBuilder,
Object[]> appender) {
+ for (int i = 0; i < 10; i++) {
+ final Object shared = new Object();
+ final ToStringBuilder builder = new ToStringBuilder(new Object(),
new RecursiveToStringStyle());
+ appender.accept(builder, new Object[] { shared, shared });
+ assertNoVisitedObjects();
+ appender.accept(builder, new Object[] { shared });
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ }
+ assertEquals(1, ToStringStyle.getRegistry().size());
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testNestedBuildersKeepSharedReferences(final ToStringStyle style) {
+ final Object shared = new Object();
+ final Object[] root = { new Object(), shared, new Object[] { shared }
};
+ final String result = new ReflectionToStringBuilder(root,
style).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testNestedBuilderWithEarlierAppends() {
+ final Object shared = new Object();
+ final ReflectionToStringBuilder nested = new
ReflectionToStringBuilder(new Object[] { shared }, new
RecursiveToStringStyle());
+ nested.append(shared);
+ final ToStringBuilder outer = new ToStringBuilder(new Object());
+ final String result = outer.append(nested).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ assertNull(builderVisited(nested));
+ }
+
+ @Test
+ void testNestedBuilderWithEarlierAppendsJoinsVisitedObjects() {
+ final Object shared = new Object();
+ final ReflectionToStringBuilder nested = new
ReflectionToStringBuilder(new Object[] { shared }, new
RecursiveToStringStyle());
+ nested.append(shared);
+ final RecursiveToStringStyle style = new RecursiveToStringStyle() {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected boolean accept(final Class<?> clazz) {
+ return clazz != ReflectionToStringBuilder.class &&
super.accept(clazz);
+ }
+ };
+ final ToStringBuilder outer = new ToStringBuilder(new Object(),
style).append(new Object());
+ final String result = outer.append(nested).append(shared).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testNullReflectionCompletionReleasesEarlierAppends() {
+ final ReflectionToStringBuilder builder = new
ReflectionToStringBuilder(null, new RecursiveToStringStyle());
+ builder.append(new Object[] { new Object() });
+ assertEquals("<null>", builder.toString());
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ }
+
+ @Test
+ void testOrphanWithEarlierAppendDoesNotPolluteLaterBuilders() {
+ new ReflectionToStringBuilder(orphan, new
RecursiveToStringStyle()).append(new Object[] { new Object() });
+ for (int i = 0; i < 10; i++) {
+ new ReflectionToStringBuilder(new Object[] { new Object() }, new
RecursiveToStringStyle()).toString();
+ assertNoVisitedObjects();
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testReflectionArrayAppendSharesVisitedObjects(final ToStringStyle
style) {
+ final Object shared = new Object();
+ final ReflectionToStringBuilder builder = new
ReflectionToStringBuilder(new Object[] { shared }, style);
+ builder.reflectionAppendArray(new Object[] { shared });
+ assertNoVisitedObjects();
+ assertEquals(1, StringUtils.countMatches(builder.toString(),
identityDetailPrefix(shared)));
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testReflectionFailureReleasesTraversal() {
+ final Object failure = new Object();
+ final Object[] root = { new Object(), failure };
+ final ReflectionToStringBuilder builder = new
ReflectionToStringBuilder(root, new FailingStyle(failure));
+ try {
+ assertThrows(IllegalStateException.class, builder::toString);
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ } finally {
+ ToStringStyle.unregister(root);
+ }
+ }
+
+ @Test
+ void testShallowBuilderDoesNotAllocateVisitedMap() {
+ final ToStringBuilder builder = new ToStringBuilder(new Object());
+ builder.append(new Object());
+ assertNull(builderVisited(builder));
+ builder.toString();
+ assertNoVisitedObjects();
+ }
+
+ @Test
+ void testValidationFailureReleasesEarlierAppends() {
+ final ReflectionToStringBuilder builder = new
ReflectionToStringBuilder(new Object(), new RecursiveToStringStyle());
+ builder.append(new Object());
+ builder.setIncludeFieldNames("value").setExcludeFieldNames("value");
+ assertThrows(IllegalStateException.class, builder::toString);
+ assertNoVisitedObjects();
+ assertNull(builderVisited(builder));
+ }
+
+ @ParameterizedTest
+ @MethodSource("styles")
+ void testVisitedObjectsUseIdentityWithoutCallingObjectMethods(final
ToStringStyle style) {
+ final Object first = new IdentityOnly();
+ final Object second = new IdentityOnly();
+ final String result = new ToStringBuilder(new Object(),
style).append(new Object[] { first, second, first }).toString();
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(first)));
+ assertEquals(1, StringUtils.countMatches(result,
identityDetailPrefix(second)));
+ assertNoVisitedObjects();
+ }
+}