Reference loops (cyclic data structures)
LPC's compound values — arrays, mappings, classes, buffers, and function
pointers — are assigned by reference: b = a makes b point at the same
array as a, it does not copy it. Nothing stops one of those references from
pointing back at (or eventually reaching) its own container, and the result is
a reference loop:
mixed *a = ({ 0 });
a[0] = a; // a self-referential array
a[0][0][0] == a; // true -- you can walk the loop forever
mapping m1 = ([]), m2 = ([]);
m1["peer"] = m2; // a two-value loop
m2["peer"] = m1;
class node { mixed next; }
class node n1 = new(class node), n2 = new(class node);
n1.next = n2; // a circular linked list
n2.next = n1;
All of this is legal. The loop behaves like any other data structure as
long as you only take one step at a time: indexing, foreach, == (which is
pointer identity for compound types), sizeof, member_array, and every
other shallow operation work normally. What a loop changes is memory
management and the behavior of the handful of operations that walk a value
recursively.
Why loops leak: reference counting has no cycle collector
The driver reclaims compound values by pure reference counting
(src/vm/internal/base/svalue.cc): every svalue holding a pointer to an
array/mapping/class counts as one reference, and the value is deallocated the
moment its count reaches zero. There is no mark-and-sweep pass, no cycle
collector — nothing ever asks "is this value still reachable?".
A reference loop keeps itself alive:
mixed *a = ({ 0 });
a[0] = a; // the array's ref count is now 2: the variable + itself
a = 0; // ref count drops to 1 -- and can never reach 0 again
After a = 0 the array is unreachable from any LPC variable, but its own
back-reference holds its count at 1 forever. The memory is leaked until the
driver shuts down. The same applies to a loop of any length: drop the last
outside reference and the whole ring stays allocated, each member kept alive
by the next.
Two properties make this leak unusually quiet:
- The ref-count comparison can't see it. A detached loop is perfectly
self-consistent — every internal reference is accounted for by another
member of the loop — so the classic "is X, should be Y" check in
check_memory()passes. (On debug buildscheck_memory()now runs a separate trial-deletion scan for exactly this case — seefind_orphaned_cycles()below.) - Leak detectors may not see it either. On Debug builds the driver's own allocation tracker keeps a pointer to every block, so tools like LeakSanitizer consider the memory "still reachable".
On release builds the only visible symptom is the slow growth of the
Arrays / Mappings counters in mud_status(1) (or memory_summary())
across a long uptime.
What the recursive consumers do with a loop
Any operation that walks a value's contents recursively would run forever on a loop. The driver guards each of them with a depth cap instead of cycle detection, so what you get is a clean, catchable error (or truncation), not a crash:
| Operation | Behavior on a loop |
|---|---|
sprintf("%O", x) | truncates with ... at nesting depth 20 |
save_object() / save_variable() | throws Mappings and/or arrays nested too deep (100) for save_object -- possibly a reference loop; see has_cycle() |
copy() (deep copy) | throws Mappings, arrays and/or classes nested too deep (100) for copy() -- possibly a reference loop; see has_cycle() |
restore_object() / restore_variable() | not applicable — the save format cannot express a loop, so restore can never create one |
==, foreach, indexing, sizeof, ... | shallow; work normally |
These caps share MAX_SAVE_SVALUE_DEPTH (100, options_internal.h); the
practical consequence is that a value containing a loop cannot be saved,
deep-copied, or fully printed. If your object's save_object() suddenly
errors with "nested too deep", a reference loop that crept into a saved
variable is the usual culprit.
All of the above is pinned by the regression test
testsuite/single/tests/operators/reference_loop.lpc.
The one loop the driver does break: through an object
Objects are the exception, because they have an explicit lifetime. When an
object is destructed, the driver zeroes the object's global variables on
the spot (destruct2() in src/vm/internal/simulate.cc) — precisely so
that "an object with a variable pointing to itself would never be freed"
cannot happen. Every reference the object's variables held is released at
destruct time, regardless of the object's own ref count.
So a loop is harmless if an object is part of the ring:
object ob = clone_object("/obj/container");
mapping m = ([ "owner" : ob ]);
ob->set_data(m); // ob.data -> m -> ob : a loop
destruct(ob); // driver clears ob's variables:
// m's ref from ob is gone, m["owner"] reads 0
destruct() cuts the ring, and plain reference counting reclaims the rest.
How to prevent reference loops (and their leaks)
-
Prefer trees over graphs in plain data. If a child needs to find its parent, consider storing a lookup key (an object name, an index into a central mapping) instead of a direct back-reference.
-
Route unavoidable back-references through an object. Store the cyclic state in a (possibly invisible) daemon or data object's variables. The structure stays cyclic and convenient, but
destruct()— including the driver's normalclean_up/swap-driven destruction — reliably reclaims it. This is the idiomatic LPC ownership pattern: the object owns the graph; the graph does not own itself. -
Break the loop before dropping the last reference. If you build transient cyclic structures, null the back-edge when you are done:
a[0] = 0; // self-referential array: cut the self-edgemap_delete(m1, "peer"); // mutual mappings: one deleted edge un-loops bothn2.next = 0; // circular list: cut any one linkOne cut edge anywhere in the ring is enough — reference counting reclaims everything downstream of the cut.
-
Never let a loop reach saved variables.
save_object()will error and your object's persistence silently stops working. Mark cache-like variables that might contain shared/cyclic datanosave. -
Audit with
refs(). Therefs(value)efun (develop package) returns the value's reference count. A freshly built structure held by one variable reports 1; if it reports more and you can't account for the extras, something (possibly itself) is holding it:mixed *a = ({ 0 });refs(a); // 1 -- just the variablea[0] = a;refs(a); // 2 -- the variable, plus the loop's own reference
Runtime cycle tools: has_cycle(), find_cycles(), break_cycles()
The contrib package ships three efuns built for exactly this problem. All
three share one iterative graph walk (no C-stack recursion), so unlike
save_variable()/copy() they have no nesting-depth limit, and they follow
every kind of edge a loop can hide in: array/class items, mapping keys and
values, and a function pointer's captured argument list.
mixed *a = ({ "x", 0 });
a[1] = a;
has_cycle(a); // 1 -- there is a loop
find_cycles(a); // ({ "[1]" }) -- one path per loop-closing slot
break_cycles(a); // 1 -- cleared it, in place
a[1]; // 0; a[0] is still "x", and refs(a) is back to 1
has_cycle(value)— cheap predicate. Ideal as a guard beforesave_object(), in a daemon's periodic self-check, or as an assertion in test code.find_cycles(value)— one index path per back-edge ("[3][\"peer\"].1"style), pointing at the exact slot that closes each loop. Diagnostics for where the loop crept in.break_cycles(value)— the safe-clearing primitive. It cuts exactly the loop-closing edges: item/value slots are zeroed in place, and a loop closed in mapping-key position has its node deleted (a hashed key cannot be overwritten). Deliberate sharing (DAG edges) is never touched, one cut un-loops a whole ring, and afterwards the value saves, copies, prints, and — crucially — frees normally. Call it before discarding any structure that might have become cyclic.
Because a broken loop is precisely the "break the back-edge" pattern from
rule 3, break_cycles() is what you reach for when the structure was built
by code you don't control (restored data run through mudlib mutators, a
generic cache, user-scripted content).
After the fact: find_orphaned_cycles() (debug builds)
The three efuns above need a variable that still reaches the loop. Once the
last outside reference is dropped, the loop is unreachable — nothing in LPC
can name it anymore. On debugging builds (DEBUGMALLOC_EXTENSIONS), the
develop package adds the missing piece:
mixed *a = ({ 0 });
a[0] = a;
a = 0; // leaked -- invisibly, until now
find_orphaned_cycles(0); // 1 -- detected after the fact
find_orphaned_cycles(1); // 1 -- detected AND reclaimed
find_orphaned_cycles(0); // 0
Detection is by trial deletion (the same idea CPython's garbage
collector uses): a data block whose every reference comes from other data
blocks, with no path from any externally held block — an object's
variables, the VM stack, a call_out, a driver-internal holder — can only
be loop garbage. Because the verdict is computed from real reference
counts, reachable data is never misclassified. With the collect flag the
garbage is reclaimed safely: values still reachable elsewhere are
untouched, and everything the loop held (strings, buffers, object refs) is
released normally.
check_memory() runs the same scan and reports
unreachable data block(s) kept alive only by reference loop(s), so on
debug builds — including the driver's own testsuite, which calls
check_memory() after every test file — dropping a cycle is now a hard,
attributed failure instead of a silent leak.
Since save_object(), save_variable() and copy() failing with "nested
too deep" is the classic symptom of a loop, those errors now say so:
... nested too deep (100) for save_object -- possibly a reference loop; see has_cycle().
For driver developers: copy()'s depth-cap error path must stay
unwind-safe — the deep-copy helpers hold their half-built containers in
RAII guards (src/packages/contrib/contrib.cc) because a cyclic argument
always takes that error path. Any new efun that walks svalues recursively
needs its own depth cap (audit checklist item — see AGENTS.md §13.4) and
the same unwind discipline.