The "Top" Argument In Dump() Will Not Protect You From Circular References In Lucee CFML 5.3.3.62
The other day, I had a typo in my ColdFusion code that was accidentally creating a circular reference in one of my data-structures. The workflow that was consuming this data-structure dealt with serialization; and, attempting to serialize the accidental circular reference was completely locking-up my Docker container. To debug this, I tried to use the dump()
function with the top
argument in an effort to see where in the structure the problem was residing - something that I demonstrated in Adobe ColdFusion 14-years ago. However, it turns out that the top
argument won't actually protect you form a circular reference in Lucee. As such, I wanted to see if I could build a wrapper to the dump()
built-in function (BIF) that would safely handle circular references in Lucee CFML 5.3.3.62.
To give you some more context, the problematic ColdFusion code looked like this:
<cfscript>
var values = [];
otherValues.each(
( value ) => {
// .... logic ....
// NOTE: TYPO - value(s) was supposed to be value.
values.append( values );
}
);
// .... logic ....
serializeJson( values );
</cfscript>
As you can see, I had accidentally appended the variable values
(with an s
) when I had meant to append the variable, value
(with no s
). What this did was append the Array to itself over-and-over again, creating a bevy of circular references. And, when I then called serializeJson()
, my Docker container would completely lock-up and I would have to force-quite out of Docker For Mac.
I knew something was wrong with the data-structure; but, I didn't know what it was. So, I attempted to use the top
argument for dump()
in an effort to incrementally output the values
object, looking for suspicious data:
<cfscript>
// .... logic ....
dump( var = values, top = 3 );
</cfscript>
But, when I used dump()
, my Docker container would lock-up; and, again, I'd have to quite out of my Docker For Mac.
This morning, I tried to replicate the same conditions in my CommandBox. And, at least CommandBox doesn't lock-up like my Docker container did. Instead, Lucee CFML gives me a reasonable error:
Here, we can see that Lucee is running into a StackOverflow error when trying to call .hashCode()
on some value internally. Not knowing much about Java, my guess is that the .hashCode()
of a ColdFusion Array (List) or a Struct (HashMap) is calculated by aggregating the .hashCode()
calls of its member values. This would lead to infinite recursion given a circular reference.
To get around this problem, I wanted to create a wrapper to the dump()
function that would handle circular references a bit more gracefully. And, I clearly had to do this in such a way that didn't require calling any .hashCode()
methods.
Luckily, I discovered that the triple equals operator (===
) in Lucee CFML will compare object references for complex objects. This gives us a way to see if two variables reference the same physical value.
To leverage this feature, I can keep an Array of complex objects. And then, given a value, I can brute-force loop over that Array and compare the given value to each existing value in the Array using ===
.
ASIDE: I couldn't use
array.contains( value )
as this function uses the.hashCode()
under the hood (as I found out) and creates the same infinite recursion that we're trying to avoid.
Ultimately, my solution was to recursively traverse a given data-structure, creating a deep copy of it that would replace circular references with the string, [circular reference]
. And then, pass this deep-copy off to the native dump()
function so that Lucee could work its normal magic.
I called this function dumpSafely()
:
<cfscript>
/**
* I wrap the execution of dump(), replacing circular references with the string,
* "[circular reference]". This works by recursing down through the data structure and
* keeping track of Complex Objects. This is much slower than the native dump(); but,
* at the cost of being somewhat safer for debugging.
*
* @var I am the value being dumped.
*/
public void function dumpSafely( any var ) {
var complexObjects = [];
// I check to see if the given Complex Value has been seen before. All complex
// objects are kept in an Array; then, when checking, we brute-force loop over
// the array and see if any of the object references match.
// --
// NOTE: We CANNOT USE complexObjects.contains() as that would cause the same
// stack-overflow problem that dump() runs into with calls to .hashCode().
var hasBeenSeen = ( value ) => {
for ( var seenObject in complexObjects ) {
if ( seenObject === value ) {
return( true );
}
}
complexObjects.append( value );
return( false );
};
// I return a copy of the given value that can be safely passed to dump().
var safeCopyForDumping = ( value ) => {
if ( isNull( value ) ) {
return;
}
if ( isSimpleValue( value ) ) {
return( value );
}
if ( ( isStruct( value ) || isArray( value ) ) && hasBeenSeen( value ) ) {
return( "[circular reference]" );
}
if ( isStruct( value ) ) {
// CAUTION: ColdFusion Components pass the isStruct() decision function,
// but do not have a .map() function. As such, we are using the safer
// built-in function, structMap().
return structMap(
value,
( key, subvalue ) => {
return( safeCopyForDumping( subvalue ?: nullValue() ) );
}
);
}
if ( isArray( value ) ) {
return arrayMap(
value,
( subvalue, index ) => {
return( safeCopyForDumping( subvalue ?: nullValue() ) );
}
);
}
// If we're not explicitly testing for a given value type, just pass-through
// the given value as-is.
// --
// NOTE: The Query / ResultSet data-type seems to already handle circular
// references property, using a "Reference" ID in lieu of the circular
// reference.
return( value );
}; // END: safeCopyForDumping.
arguments.label = ( arguments.keyExists( "label" ) )
? "DUMP SAFELY ( #arguments.label# )"
: "DUMP SAFELY"
;
arguments.var = safeCopyForDumping( arguments.var ?: nullValue() );
// Now that we've replaced the "var" argument with one that is safe for dumping,
// we can go ahead and call the native dump() method with all additional
// arguments that may have been passed-in.
dump( argumentCollection = arguments );
}
</cfscript>
As you can see, the dumpSafely()
function keeps a running aggregate of Arrays and Structs in the variable, complexObjects
. Then, every time my deep-cloning algorithm runs into an Array or a Struct, it first checks to see if the value exists in the complexObjects
collection. And, if it does, it replaces the reference with the string, [circular reference]
.
Once the deep-clone has been made, I just pass it off to dump()
, along with any other arguments that were originally passed into the dumpSafely()
function.
ASIDE: Interestingly enough, the Query / RecordSet type already seems to handle circular references gracefully, replacing them with "Reference XXX".
To see this in action, let's create a Struct with some wonky circular reference action:
<cfscript>
values = {
a: {
b: {
c: {
n: nullValue()
},
cd: 3
}
},
aa: {
bb: "bbthing",
cc: "ccthing"
},
aaa: {
thing: new Thing()
},
aaaa: [
"blooper",
"moopsy"
]
};
// Create circular references in the data-structure.
values.a.b.c.values = values;
values.aaa.circ = values;
values.aaa.thing.oops = values.aaa.thing;
values.aaa.thing.doops = values;
values.aaaa.append( values.aaa.thing );
// dump( var = values, top = 2 );
include "./dump-safely.cfm";
dumpSafely(
var = values,
label = "Testing Circular References"
);
</cfscript>
As you can see, I'm creating a cornucopia of circular references. And, when we pass this data off to dumpSafely()
, we get the following output:
Each of the circular references that I created in my data-structure was gracefully replaced with, [circular reference]
, inside of my deep-copy. This allows the underlying call to dump()
to execute without infinite recursion!
Obviously, my dumpSafely()
wrapper is going to take a performance hit by both making a deep-copy of the given variable and then having to iterate over an internal array, checking every complex data-type; and, I'm going to lose the fidelity of some of the data-types (namely ColdFusion components which now get reported as Structs); but, seeing as this is a hail-Mary approach to debugging the circular references in my code, it seems reasonable enough to me in Lucee CFML 5.3.3.62.
Want to use code from this post? Check out the license.
Reader Comments