reference loops: docs, runtime cycle efuns, orphan collector, copy() unwind fix (#1276)

A complete treatment of reference loops (cyclic data structures) in LPC:
the reference-counting VM has no cycle collector, so a value that reaches
itself leaks permanently -- and silently -- once the last outside
reference is dropped, and it cannot be saved, deep-copied, or fully
printed in the meantime. This change documents the problem, fixes a
driver memory-safety bug it exposes, and adds runtime tooling to detect,
locate, break, and (on debug builds) collect such loops.

Driver fixes:

- copy() on a cyclic structure always hits the MAX_SAVE_SVALUE_DEPTH
  error(), and that unwind path leaked every partially-built container --
  allocated with the _empty_ (uninitialized-svalue) allocators and holding
  a borrowed, un-ref-counted pointer to the ORIGINAL value, which the
  Debug memory checker then read after free (heap-use-after-free abort
  under ASan, reproducible with just catch(copy(a)) on a[0] = a).
  deep_copy_* now allocate zero-filled, hold the destination in a
  unique_ptr whose deleter is the tag-symmetric free_*, and only write the
  destination slot after the child copy fully succeeded (ffi.cc precedent,
  AGENTS.md section 4).
- save_object/save_variable and copy()'s 'nested too deep' errors -- the
  classic symptom of a loop -- now say so (the has_cycle() pointer is
  gated on PACKAGE_CONTRIB so core never recommends an efun the build
  lacks).

New efuns (contrib package, src/packages/contrib/cycles.cc):

- has_cycle(mixed): 1 if the value's reference graph contains a loop.
- find_cycles(mixed): one index-path string per loop-closing slot
  ("[3][\"peer\"].1" style).
- break_cycles(mixed): clears every loop in place and returns the number
  of edges broken. Exactly the DFS back-edges are touched (a digraph is
  acyclic iff its DFS has no back-edges): item/value slots are zeroed, a
  loop closed in mapping-KEY position has its node deleted (hashed keys
  cannot be overwritten), and a loop closing on the funptr->args edge
  itself -- possible because bind() SHARES the args array between the old
  and new funptr -- detaches the bound funptr's args list and replaces it
  with a zero-filled one of the same size. DAG sharing is never touched;
  one cut un-loops a whole ring; afterwards the value saves, copies,
  prints, and frees normally.

  All three share one ITERATIVE walk (explicit heap stack, white/grey/
  black coloring): no C-stack recursion, no depth cap -- arbitrarily deep
  acyclic values scan cleanly where save_variable() errors. Edges:
  array/class items, mapping keys AND values, fp->hdr.args; objects are
  deliberately leaves (loops through object variables are the
  destruct()-managed kind: destruct2() zeroes the variable block).
  break_cycles() records fixes during a mutation-free walk and applies
  them in a post-pass that holds a reference on every touched container,
  zeroes slots before deleting nodes (only node deletion can cascade
  frees), and releases the holds last -- order-independent and safe
  against shared/overlapping fixes.

Orphaned-loop collector (develop package, Debug/DEBUGMALLOC_EXTENSIONS):

- find_orphaned_cycles(int collect): finds -- and with any nonzero
  argument reclaims -- data blocks that are unreachable because only a
  reference loop keeps them alive: the case nothing LPC-level can reach
  anymore. Detection is trial deletion (CPython-gc-style), implemented in
  md_scan_orphaned_cycles (checkmemory.cc): count each array/class/
  mapping/funptr's references held by OTHER data blocks; a block whose
  real ref count exceeds that is externally held (object variables, VM
  stack, call_out, any C++-side holder) and seeds liveness, which
  propagates along data edges; the remainder is loop garbage. No root
  enumeration to get wrong -- every legitimate holder shows up as an
  external ref. Collection: hold a ref on every dead block, sever all
  their child slots (releasing strings/objects/buffers/live values
  normally), then release the holds -- each dead block deallocates with
  nothing left to cascade into.
- check_all_blocks() runs the same scan (skippable via new flag bit 2,
  value 4) and reports 'unreachable data block(s) kept alive only by
  reference loop(s)', so the testsuite's per-file check_memory() gate
  turns a dropped cycle into a hard, attributed failure. That immediately
  caught a real pre-existing leak: tests/std/json.lpc's
  test_encode_circular_references() dropped all four of its
  deliberately-cyclic fixtures on every suite run since it was written.

Tests (testsuite/single/tests/):

- operators/reference_loop.lpc pins the driver contract around loops and
  crashes the unfixed Debug/ASan driver (the copy() unwind UAF).
- efuns/has_cycle.lpc, find_cycles.lpc, break_cycles.lpc cover self/
  mutual/ring loops across arrays, mappings (value and key position),
  classes, funptr args (including the bind()-shared-args case, which was
  unbreakable in an earlier revision of this change), DAG-sharing
  preservation, save/copy working again after a break, idempotency, and a
  5000-deep acyclic walk.
- efuns/find_orphaned_cycles.lpc pins baseline-relative detection of 6
  orphans across three dropped loop shapes, idempotent detection, that
  reachable loops are never classified as garbage, and that collection
  reclaims everything while reachable data survives.
- Every cycle-building test has UNCONDITIONAL teardown (body in catch(),
  find_orphaned_cycles(1) regardless, error re-raised) so a mid-test
  regression stays one [ FAILED ] entry instead of cascading the
  harness's LEAK gate into a suite-wide abort (AGENTS.md section 7).

Docs (Docusaurus, sidebar regenerated; full two-locale build verified):

- new concepts page docs/concepts/general/reference_loops.md: why loops
  leak, what each recursive consumer does, the destruct() exception,
  prevention patterns, the runtime tools, and the debug-build collector;
- efun pages for all four new efuns; check_memory.md documents the new
  scan and flag bit.

Validated on Debug+ASan/UBSan (full LPC suite, randomized order, multiple
runs) and RelWithDebInfo (full suite), 313 GTest unit tests, plus an
8-angle adversarially-verified self-review.

Round-2 self-review (4 fresh angles, adversarially verified) additionally:
- break_cycles() post-pass releases its held container references via an
  RAII guard: allocate_array() there can error() (set_config() can shrink
  __MAX_ARRAY_SIZE__ at runtime below a shared args array's size), and the
  old trailing release loop would have leaked every held ref on that
  unwind (AGENTS.md section 4).
- documented the pre-existing map_delete()-class caveat: deleting a
  key-closed loop's node while an outer unlocked foreach-ref variable is
  aimed at it dangles that variable (not specific to this efun; noted in
  code and doc).
- extended orphan-collector coverage from 6 to 10 blocks: class rings
  (TAG_CLASS candidate/sever/free_class paths), mapping pairs closed in
  KEY position (the collector's in-place key-zeroing sever path), and a
  buffer payload riding an orphaned ring (sever must release it or the
  Debug ref gate trips); added a destructed-object-in-walked-value test
  (render_key + leaf handling).
- docs: refs.md and copy.md now link back to the cycle tooling; zh-CN
  sidebar translation keys rescaffolded; AGENTS.md section 7 documents the
  new hard gate and the catch + find_orphaned_cycles(1) teardown pattern.
- re-entrancy audit (foreach/MAP_LOCKED/locked_map_nodes/merge_arg_lists)
  and LPC-test-semantics audit returned no code defects.


Claude-Session: https://claude.ai/code/session_01VaksxbPjc3hjzghoQPUHRo

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yucong Sun 2026-07-16 00:06:37 -07:00 committed by GitHub
parent b040aae3c9
commit 86d13cdb83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1860 additions and 48 deletions

View file

@ -166,7 +166,7 @@ FluffOS uses GitHub Actions for CI on pull requests and pushes to `master`.
* **Testing Targets**:
- `driver-testsuite`: Boots the local driver pointing to the test configuration.
- `driver-fulltest` / `driver-autotest`: Runs the LPC test suite and reports results before exiting.
* **Harness facts that bite:** LPC fixture files must live OUTSIDE `testsuite/single/tests/` (use `/clone/...` or write them at runtime under `/data/...`) -- the runner executes every `tests/**/*.lpc` in RANDOMIZED order. Tests that register global state (e.g. `master->set_compile_hooks()`) must tear it down UNCONDITIONALLY: ASSERT macros record-and-continue, so wrap the body in `catch(run_checks())`, clean up, then re-`error()`. `master::flag()` sits on the call stack for the entire run (so e.g. the master object can never be recompiled from inside a test; use a `call_out` that fires post-run and `shutdown(-1)`s on failure -- call_outs only fire after the suite in FULL runs, never in single-file runs, which shut down synchronously). A plain `-ftest:` filter needs the FULL test path; suite runs dirty `testsuite/aw_test.txt` and `testsuite/trace_test.json` (restore before committing). `file_name(ob)` returns a leading slash.
* **Harness facts that bite:** LPC fixture files must live OUTSIDE `testsuite/single/tests/` (use `/clone/...` or write them at runtime under `/data/...`) -- the runner executes every `tests/**/*.lpc` in RANDOMIZED order. A test that builds a **reference loop** (cyclic array/mapping/class/funptr-args structure) must break it before returning -- on Debug builds the post-file `check_memory()` hard-fails on dropped loops (`unreachable data block(s) kept alive only by reference loop(s)`); the robust teardown is `catch(run_checks())`, then an unconditional `#if efun_defined(find_orphaned_cycles) find_orphaned_cycles(1); #endif`, then re-`error()` (see `tests/efuns/break_cycles.lpc` and docs/concepts/general/reference_loops.md). Tests that register global state (e.g. `master->set_compile_hooks()`) must tear it down UNCONDITIONALLY: ASSERT macros record-and-continue, so wrap the body in `catch(run_checks())`, clean up, then re-`error()`. `master::flag()` sits on the call stack for the entire run (so e.g. the master object can never be recompiled from inside a test; use a `call_out` that fires post-run and `shutdown(-1)`s on failure -- call_outs only fire after the suite in FULL runs, never in single-file runs, which shut down synchronously). A plain `-ftest:` filter needs the FULL test path; suite runs dirty `testsuite/aw_test.txt` and `testsuite/trace_test.json` (restore before committing). `file_name(ob)` returns a leading slash.
* **The LPC suite is a real pass/fail gate, registered with ctest.** the ctest test is named `testsuite``ctest -R testsuite` (equivalently the `driver-autotest` CMake target) runs `driver etc/config.test -ftest`; CI runs `ctest -LE testsuite` (GTest) and `ctest -L testsuite` (this suite) as separate steps. The runner (`testsuite/command/tests.lpc`) prints gtest-style `[ RUN ]/[ OK ]/[ FAILED ]` blocks with per-file timing; **failed checks are recorded and the run continues** (one run reports every failure), then a recap lists the failed files and the driver exits nonzero. A clean run prints `Checks succeeded.` and exits 0 — that pair is the sound pass signal. Filter runs with `-ftest:single/tests/efuns/foo.lpc` (one file) or `-ftest:efuns/dual*` (glob). When touching the lexer/parser, run the suite 23× (it randomizes test file order) and prefer both a Debug ASan build and a `RelWithDebInfo` build (some issues are release-only).
* **Regression tests for a memory-safety fix should fail (crash / ASan abort / leak / wrong output) on the UNFIXED binary** -- verify that, not just that they pass now. Patterns that work from LPC: drive a **compile-time** bug by building source at runtime (`write_file("/gen.c", src); load_object("/gen")`) -- e.g. a >4096-char local name to hit a compiler buffer overflow (`single/tests/compiler/long_local_name.lpc`); drive an **efun** bug by feeding the boundary argument (an out-of-range index, an `INT_MIN` operand, a crafted deep-nested save string) and asserting a clean `catch(...)` error instead of a crash (`efuns/sys_reload_tls.lpc`, `efuns/restore_variable.lpc`). A flaky ref-count / threading bug (see §3) won't reproduce deterministically -- validate those with an ASan build + a repeated full-suite loop instead. **A single flaky Debug failure at an `async_*` test is almost always the ref-checker race, not your diff** -- confirm the same suite passes on the other Debug configs before treating it as real.

View file

@ -0,0 +1,240 @@
---
title: general / reference_loops
---
# 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**:
```c
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:
```c
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 builds `check_memory()` now runs a
*separate* trial-deletion scan for exactly this case — see
[`find_orphaned_cycles()`](#after-the-fact-find_orphaned_cycles-debug-builds)
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**:
```c
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)
1. **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.
2. **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 normal `clean_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.
3. **Break the loop before dropping the last reference.** If you build
transient cyclic structures, null the back-edge when you are done:
```c
a[0] = 0; // self-referential array: cut the self-edge
map_delete(m1, "peer"); // mutual mappings: one deleted edge un-loops both
n2.next = 0; // circular list: cut any one link
```
One cut edge anywhere in the ring is enough — reference counting reclaims
everything downstream of the cut.
4. **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 data `nosave`.
5. **Audit with `refs()`.** The `refs(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:
```c
mixed *a = ({ 0 });
refs(a); // 1 -- just the variable
a[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.
```c
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 before
`save_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:
```c
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.

View file

@ -0,0 +1,75 @@
---
title: contrib / break_cycles
---
# break_cycles
### NAME
break_cycles() - safely clear every reference loop in a value
### SYNOPSIS
int break_cycles(mixed value);
### DESCRIPTION
Clears every reference loop in `value` IN PLACE and returns the number
of edges that were broken. Because compound values are passed by
reference, the caller's value (and every other holder of it) sees the
change.
Exactly the loop-closing back-edges are touched, nothing else:
- an array/class item or mapping VALUE that closes a loop is
overwritten with 0;
- a mapping KEY that closes a loop cannot be overwritten (the node is
hashed by that key), so the whole key/value node is deleted, exactly
as if map_delete() had been called;
- a slot inside a function pointer's captured argument list that
closes a loop is overwritten with 0 (the function pointer itself
survives, its captured argument becomes 0); when the loop closes on
the argument-list edge itself -- possible because bind() shares the
argument list between the old and the new function pointer -- the
bound function pointer's whole argument list is detached and
replaced with a zero-filled one of the same size;
- everything that is not part of a loop -- including deliberate
sharing of one structure from several places -- is left untouched.
One broken edge un-loops an entire ring, so a mutual pair or a ring of
N containers counts as 1, not N.
Afterwards has_cycle(value) is 0 and the value can be saved with
save_object()/save_variable(), deep-copied with copy(), and printed
with sprintf("%O") without hitting the nesting-depth errors that a
loop otherwise causes -- and dropping the last reference actually
frees the memory instead of leaking it.
Call it before discarding any structure that might have become
cyclic, e.g. from a generic cache daemon's clean-up path:
void flush() {
break_cycles(cache);
cache = ([]);
}
The traversal is iterative and has no nesting-depth limit.
Caveat (shared with map_delete()): if a loop is closed in mapping-KEY
position, the node deletion carries the same restriction as deleting
a mapping entry from inside a `foreach (key, ref value in m)` loop
over that same mapping -- do not call break_cycles(m) from inside
such a loop while the ref variable is aimed at the entry being
removed.
### EXAMPLE
mixed *a = ({ "keep", 0 });
a[1] = a; // loop
break_cycles(a); // 1
a[0]; // "keep" -- untouched
a[1]; // 0 -- back-edge cleared
save_variable(a); // works again
### SEE ALSO
has_cycle(3), find_cycles(3), refs(3), map_delete(3)

View file

@ -25,3 +25,14 @@ title: contrib / copy.pre
This is particularly useful when you wish to have data that is passed
by reference, but do not want to alter the original.
### ERRORS
Values nested deeper than 100 levels throw "Mappings, arrays and/or
classes nested too deep (100) for copy() -- possibly a reference loop;
see has_cycle()". A value containing a reference loop always exceeds
the cap; test with has_cycle(3) and clear with break_cycles(3).
### SEE ALSO
has_cycle(3), break_cycles(3), save_object(3), restore_object(3)

View file

@ -0,0 +1,50 @@
---
title: contrib / find_cycles
---
# find_cycles
### NAME
find_cycles() - locate every reference loop in a value
### SYNOPSIS
string *find_cycles(mixed value);
### DESCRIPTION
Returns one index path per back-edge -- per slot that closes a
reference loop in `value`. An empty array means the value is acyclic.
Breaking (or deleting) exactly the returned slots would make the value
loop-free; break_cycles() does that in one call.
Path syntax, concatenated from the outermost container inward:
[3] array item 3
.2 class field 2 (declaration order)
["name"] mapping value under key "name"
[key <map>] a mapping KEY (the key itself continues the path)
(args) a function pointer's captured argument list
Which slot of a loop is reported depends on traversal order (array
index order, mapping table order), so treat the paths as diagnostics,
not as a stable contract. Long string keys are truncated in the
rendering.
The traversal is iterative and has no nesting-depth limit.
### EXAMPLE
mixed *a = ({ "x", 0 });
a[1] = a;
find_cycles(a); // ({ "[1]" })
mapping m = ([]);
m["self"] = m;
find_cycles(m); // ({ "[\"self\"]" })
find_cycles(({ 1, ({ 2 }) })); // ({ }) -- acyclic
### SEE ALSO
has_cycle(3), break_cycles(3), refs(3)

View file

@ -0,0 +1,46 @@
---
title: contrib / has_cycle
---
# has_cycle
### NAME
has_cycle() - test whether a value contains a reference loop
### SYNOPSIS
int has_cycle(mixed value);
### DESCRIPTION
Returns 1 if the reference graph of `value` contains a loop (a
structure that can reach itself), 0 otherwise.
The walk follows array and class items, mapping keys and values, and a
function pointer's captured argument list. Objects are leaves: a loop
routed through an object's global variables is reclaimed by destruct()
and is not reported here.
Sharing without a loop (the same array referenced from two places) is
NOT a cycle and returns 0.
The traversal is iterative, so unlike save_variable() or copy() it has
no nesting-depth limit: arbitrarily deep acyclic values scan cleanly.
Values containing a loop cannot be saved, deep-copied, or fully
printed, and once the last outside reference is dropped they leak
permanently (the driver reclaims values by reference counting and has
no cycle collector). See the "Reference Loops" concepts page for the
full story.
### EXAMPLE
mixed *a = ({ 0 });
a[0] = a;
has_cycle(a); // 1
a[0] = 0;
has_cycle(a); // 0
### SEE ALSO
find_cycles(3), break_cycles(3), refs(3), copy(3)

View file

@ -24,10 +24,20 @@ title: internals / check_memory
down by allocation source.
Bit 1 (value 2) Runs silently -- suppresses the warnings and report
and returns 0 instead of the report string.
Bit 2 (value 4) Skips the orphaned-reference-loop scan (see below).
Unless bit 1 or bit 2 is set, the report also includes a scan for
data blocks that are unreachable because only a reference loop keeps
them alive (`unreachable data block(s) kept alive only by reference
loop(s)`) -- garbage that pure reference counting can never reclaim
and that the plain ref-count comparison cannot see. Reclaim such
blocks with find_orphaned_cycles(1). The driver testsuite calls
check_memory() after every test file, so a test that drops a cyclic
structure without breaking it first now fails with that warning.
This efun is available only in DEBUGMALLOC builds (compiled with
DEBUGMALLOC_EXTENSIONS); it does not exist in ordinary builds.
### SEE ALSO
dump_stralloc(3), reclaim_objects(3)
find_orphaned_cycles(3), dump_stralloc(3), reclaim_objects(3)

View file

@ -0,0 +1,55 @@
---
title: internals / find_orphaned_cycles
---
# find_orphaned_cycles
### NAME
find_orphaned_cycles() - detect (and reclaim) memory leaked by dropped
reference loops
### SYNOPSIS
int find_orphaned_cycles(int collect);
### DESCRIPTION
Scans every live array, class, mapping, and function pointer in the
driver and returns the number of blocks that are UNREACHABLE -- kept
alive only by a reference loop after the last outside reference was
dropped. Pure reference counting can never reclaim such blocks, and no
LPC-level tool (refs(), break_cycles()) can reach them anymore, because
by definition no variable leads to them.
With collect != 0 the orphaned blocks are also reclaimed, safely:
strings, buffers, and objects they referenced are released normally,
values still reachable elsewhere are untouched, and the loop members
themselves are freed. This is a true (if manual) cycle collector.
Detection is by trial deletion: a data block whose every reference is
accounted for by 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), is garbage. Reachable loops are NOT reported -- use
has_cycle() / find_cycles() / break_cycles() while you still hold a
reference.
Only available on debugging builds (DEBUGMALLOC_EXTENSIONS), because it
needs the debug allocator's registry of every live block; guard call
sites with `#if efun_defined(find_orphaned_cycles)`. On those builds,
check_memory() also reports orphaned loops (`unreachable data
block(s) kept alive only by reference loop(s)`), which makes a dropped
cycle a hard failure in the driver testsuite.
### EXAMPLE
mixed *a = ({ 0 });
a[0] = a; // build a loop...
a = 0; // ...and drop it: leaked, invisibly
find_orphaned_cycles(0); // 1 -- found it
find_orphaned_cycles(1); // 1 -- found it and reclaimed it
find_orphaned_cycles(0); // 0
### SEE ALSO
has_cycle(3), find_cycles(3), break_cycles(3), check_memory(3), refs(3)

View file

@ -17,7 +17,14 @@ title: internals / refs
useful for deciding whether or not to make a copy of a data structure
before returning it.
### NOTES
A structure held by a single variable reports 1; a higher count you
cannot account for often means the structure references itself -- see
has_cycle(3) and the "Reference Loops" concepts page.
### SEE ALSO
children(3), inherit_list(3), deep_inherit_list(3), objects(3)
children(3), inherit_list(3), deep_inherit_list(3), objects(3),
has_cycle(3), find_cycles(3), break_cycles(3), find_orphaned_cycles(3)

View file

@ -2678,5 +2678,33 @@
"sidebar.docs.doc.Build (v2017, legacy)": {
"message": "构建v2017旧版",
"description": "The label for the doc item 'Build (v2017, legacy)' in sidebar 'docs', linking to the doc build_v2017"
},
"sidebar.docs.doc.efun/buffers/to_buffer": {
"message": "to_buffer",
"description": "The label for the doc item 'to_buffer' in sidebar 'docs', linking to the doc efun/buffers/to_buffer"
},
"sidebar.docs.doc.efun/contrib/break_cycles": {
"message": "break_cycles",
"description": "The label for the doc item 'break_cycles' in sidebar 'docs', linking to the doc efun/contrib/break_cycles"
},
"sidebar.docs.doc.efun/contrib/find_cycles": {
"message": "find_cycles",
"description": "The label for the doc item 'find_cycles' in sidebar 'docs', linking to the doc efun/contrib/find_cycles"
},
"sidebar.docs.doc.efun/contrib/has_cycle": {
"message": "has_cycle",
"description": "The label for the doc item 'has_cycle' in sidebar 'docs', linking to the doc efun/contrib/has_cycle"
},
"sidebar.docs.doc.efun/internals/find_orphaned_cycles": {
"message": "find_orphaned_cycles",
"description": "The label for the doc item 'find_orphaned_cycles' in sidebar 'docs', linking to the doc efun/internals/find_orphaned_cycles"
},
"sidebar.docs.doc.apply/master/valid_ffi": {
"message": "valid_ffi",
"description": "The label for the doc item 'valid_ffi' in sidebar 'docs', linking to the doc apply/master/valid_ffi"
},
"sidebar.docs.doc.concepts/general/reference_loops": {
"message": "Reference Loops",
"description": "The label for the doc item 'Reference Loops' in sidebar 'docs', linking to the doc concepts/general/reference_loops"
}
}

View file

@ -176,6 +176,7 @@
"fluffos_driver",
"simul_efun",
"message_doc",
"reference_loops",
"hot_reload",
"socket_efuns",
"tls",
@ -191,6 +192,7 @@
"fluffos_driver": "The FluffOS Driver",
"simul_efun": "Simulated Efuns",
"message_doc": "The message() System",
"reference_loops": "Reference Loops",
"hot_reload": "Hot Reload",
"socket_efuns": "Socket Efuns",
"tls": "TLS",

View file

@ -295,6 +295,12 @@
"key": "efun/contrib/base_name",
"label": "base_name"
},
{
"type": "doc",
"id": "efun/contrib/break_cycles",
"key": "efun/contrib/break_cycles",
"label": "break_cycles"
},
{
"type": "doc",
"id": "efun/contrib/classes",
@ -349,6 +355,12 @@
"key": "efun/contrib/file_length",
"label": "file_length"
},
{
"type": "doc",
"id": "efun/contrib/find_cycles",
"key": "efun/contrib/find_cycles",
"label": "find_cycles"
},
{
"type": "doc",
"id": "efun/contrib/function_owner",
@ -373,6 +385,12 @@
"key": "efun/contrib/get_os_env",
"label": "get_os_env"
},
{
"type": "doc",
"id": "efun/contrib/has_cycle",
"key": "efun/contrib/has_cycle",
"label": "has_cycle"
},
{
"type": "doc",
"id": "efun/contrib/heart_beats",
@ -1728,6 +1746,12 @@
"key": "efun/internals/dumpallobj",
"label": "dumpallobj"
},
{
"type": "doc",
"id": "efun/internals/find_orphaned_cycles",
"key": "efun/internals/find_orphaned_cycles",
"label": "find_orphaned_cycles"
},
{
"type": "doc",
"id": "efun/internals/get_config",
@ -3593,6 +3617,12 @@
"key": "concepts/general/message_doc",
"label": "The message() System"
},
{
"type": "doc",
"id": "concepts/general/reference_loops",
"key": "concepts/general/reference_loops",
"label": "Reference Loops"
},
{
"type": "doc",
"id": "concepts/general/hot_reload",

View file

@ -31,6 +31,12 @@ typedef struct md_node_s {
#define MD_OVERHEAD (sizeof(md_node_t))
#endif
void check_all_blocks(int);
#ifdef DEBUGMALLOC_EXTENSIONS
// Orphaned reference-loop scan (trial deletion); see checkmemory.cc.
// Returns the number of unreachable array/class/mapping/funptr blocks;
// reclaims them when collect is nonzero. ob (nullable) receives detail.
int md_scan_orphaned_cycles(int collect, outbuffer_t* ob);
#endif
#define MD_TABLE_BITS 14u
#define MD_TABLE_SIZE (1u << MD_TABLE_BITS)

View file

@ -1,5 +1,6 @@
if(${PACKAGE_CONTRIB})
add_library(package_contrib STATIC
"contrib.cc"
"cycles.cc"
)
endif()

View file

@ -2,6 +2,8 @@
#include <sys/stat.h> // for struct stat
#include <memory> // for std::unique_ptr (deep_copy_* error-unwind guards)
#include "packages/core/heartbeat.h"
#include "packages/core/add_action.h"
#include "packages/core/file.h"
@ -220,28 +222,29 @@ static int depth;
static void deep_copy_svalue(svalue_t* /*from*/, svalue_t* /*to*/);
// The deep_copy_* helpers must survive an error() unwind from a nested
// deep_copy_svalue() -- the MAX_SAVE_SVALUE_DEPTH cap fires on every
// self-referential (cyclic) structure, and mapping_too_large() can fire from
// doCopy(). Two rules keep that safe: allocate the destination ZEROED (the
// _empty_ allocators leave svalues uninitialized, which the Debug memory
// checker would then walk), and hold it in a unique_ptr so the half-built
// copy is freed instead of leaking with borrowed pointers inside.
static array_t* deep_copy_array(array_t* arg) {
array_t* vec;
int i;
vec = allocate_empty_array(arg->size);
for (i = 0; i < arg->size; i++) {
std::unique_ptr<array_t, void (*)(array_t*)> vec(allocate_array(arg->size), free_array);
for (int i = 0; i < arg->size; i++) {
deep_copy_svalue(&arg->item[i], &vec->item[i]);
}
return vec;
return vec.release();
}
static array_t* deep_copy_class(array_t* arg) {
array_t* vec;
int i;
vec = allocate_empty_class_by_size(arg->size);
for (i = 0; i < arg->size; i++) {
std::unique_ptr<array_t, void (*)(array_t*)> vec(allocate_class_by_size(arg->size), free_class);
for (int i = 0; i < arg->size; i++) {
deep_copy_svalue(&arg->item[i], &vec->item[i]);
}
return vec;
return vec.release();
}
static int doCopy(mapping_t* /*map*/, mapping_node_t* elt, void* dest) {
@ -258,48 +261,44 @@ static int doCopy(mapping_t* /*map*/, mapping_node_t* elt, void* dest) {
}
static mapping_t* deep_copy_mapping(mapping_t* arg) {
mapping_t* map;
map = allocate_mapping(0); /* this should be fixed. -Beek */
mapTraverse(arg, doCopy, map); /* Not horridly efficient either */
return map;
std::unique_ptr<mapping_t, void (*)(mapping_t*)> map(allocate_mapping(0), free_mapping);
mapTraverse(arg, doCopy, map.get()); /* Not horridly efficient either */
return map.release();
}
// Copy the child FIRST, then write the destination slot in one go: `to`
// points into a container that outlives an error() unwind (freed by the
// guards above), so it must never hold `from`'s pointer without owning a
// reference while a nested copy can still throw.
static void deep_copy_svalue(svalue_t* from, svalue_t* to) {
switch (from->type) {
case T_ARRAY:
depth++;
if (depth > MAX_SAVE_SVALUE_DEPTH) {
depth = 0;
error("Mappings, arrays and/or classes nested too deep (%d) for copy()\n",
MAX_SAVE_SVALUE_DEPTH);
}
*to = *from;
to->u.arr = deep_copy_array(from->u.arr);
depth--;
break;
case T_CLASS:
case T_MAPPING: {
depth++;
if (depth > MAX_SAVE_SVALUE_DEPTH) {
depth = 0;
error("Mappings, arrays and/or classes nested too deep (%d) for copy()\n",
MAX_SAVE_SVALUE_DEPTH);
error(
"Mappings, arrays and/or classes nested too deep (%d) for copy() "
"-- possibly a reference loop; see has_cycle()\n",
MAX_SAVE_SVALUE_DEPTH);
}
*to = *from;
to->u.arr = deep_copy_class(from->u.arr);
depth--;
break;
case T_MAPPING:
depth++;
if (depth > MAX_SAVE_SVALUE_DEPTH) {
depth = 0;
error("Mappings, arrays and/or classes nested too deep (%d) for copy()\n",
MAX_SAVE_SVALUE_DEPTH);
}
*to = *from;
to->u.map = deep_copy_mapping(from->u.map);
svalue_t nv = *from;
switch (from->type) {
case T_ARRAY:
nv.u.arr = deep_copy_array(from->u.arr);
break;
case T_CLASS:
nv.u.arr = deep_copy_class(from->u.arr);
break;
case T_MAPPING:
nv.u.map = deep_copy_mapping(from->u.map);
break;
}
*to = nv;
depth--;
break;
}
case T_BUFFER:
*to = *from;
to->u.buf = allocate_buffer(from->u.buf->size);

View file

@ -59,3 +59,11 @@ mixed *classes(object, int default : 0);
int test_load(string);
string get_os_env(string);
int set_os_env(string, string | void);
/*
* Reference-loop (cycle) introspection -- see cycles.cc and
* docs/concepts/general/reference_loops.md.
*/
int has_cycle(mixed);
string *find_cycles(mixed);
int break_cycles(mixed);

View file

@ -0,0 +1,377 @@
// Reference-loop (cycle) introspection efuns: has_cycle(), find_cycles(),
// break_cycles().
//
// The VM reclaims compound values by pure reference counting with no cycle
// collector, so a structure that reaches itself leaks permanently once the
// last outside reference is dropped (see docs/concepts/general/
// reference_loops.md). These efuns give the mudlib tools to find such loops
// and to break them safely.
//
// All three share one traversal: an ITERATIVE depth-first search (explicit
// heap stack -- mudlib data can nest arbitrarily deep, so no C-stack
// recursion and no MAX_SAVE_SVALUE_DEPTH cap) over the value graph, with the
// classic white/grey/black coloring. An edge whose target is grey (still
// open on the current DFS path) is a back-edge; a directed graph is acyclic
// iff its DFS finds no back-edges, so breaking exactly the back-edges is the
// minimal edit that makes a value loop-free while leaving all other sharing
// (DAG edges to already-finished nodes) untouched.
//
// Traversed edges: array/class items, mapping keys AND values, and a
// function pointer's captured argument list (fp->hdr.args). Objects are
// deliberately leaves: a loop routed through an object's global variables is
// the destruct()-managed kind (destruct2() zeroes the variable block), and a
// value walk has no business reading other objects' variables.
// NOTE: this edge set must stay in sync with each_child() in
// src/packages/develop/checkmemory.cc (md_scan_orphaned_cycles), or these
// efuns and the Debug orphan scan will disagree about what a loop is.
//
// The walk itself never mutates the graph and never frees anything, so the
// raw pointers used as color-map keys stay valid for the whole walk.
// break_cycles() records the slots to clear during the walk and applies them
// in a post-pass; see the ordering comments in f_break_cycles().
#include "base/package_api.h"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <vector>
#if defined(F_HAS_CYCLE) || defined(F_FIND_CYCLES) || defined(F_BREAK_CYCLES)
namespace {
enum : char { COLOR_GREY = 1, COLOR_BLACK = 2 };
enum WalkMode { WALK_DETECT, WALK_FIND, WALK_BREAK };
// Which kind of slot a back-edge sits in decides how break_cycles() clears
// it: item/value slots are overwritten with 0 in place; a back-edge in
// mapping-KEY position cannot be overwritten (the node is hashed by the
// key's pointer, and a second zero key could collide), so the whole node is
// deleted instead; a back-edge on the funptr->args edge (possible because
// bind() SHARES the args array between the old and new funptr) has no
// svalue slot at all, so the funptr's args list is replaced with a
// zero-filled one of the same size.
enum SlotKind { SLOT_ITEM, SLOT_MAP_VALUE, SLOT_MAP_KEY, SLOT_FP_ARGS };
struct PendingFix {
SlotKind kind;
svalue_t container; // owns a reference until the post-pass is done
svalue_t* slot; // ITEM/MAP_VALUE: slot to zero; MAP_KEY: &node->values[0];
// FP_ARGS: null (container is the owning funptr)
};
struct Frame {
unsigned short type; // T_ARRAY, T_CLASS, T_MAPPING, or T_FUNCTION
union {
array_t* arr;
mapping_t* map;
funptr_t* fp;
void* ptr;
} u;
int idx; // next item (array/class), current bucket (mapping),
// 0 = args pending / 1 = done (function)
mapping_node_t* node; // current node within the bucket (mapping)
int phase; // 0 = key next, 1 = value next (mapping)
};
struct WalkResult {
bool found = false;
std::vector<std::string> paths; // WALK_FIND
std::vector<PendingFix> fixes; // WALK_BREAK
};
void* compound_ptr(const svalue_t* sv) {
switch (sv->type) {
case T_ARRAY:
case T_CLASS:
return reinterpret_cast<void*>(sv->u.arr);
case T_MAPPING:
return reinterpret_cast<void*>(sv->u.map);
case T_FUNCTION:
return reinterpret_cast<void*>(sv->u.fp);
}
return nullptr;
}
// Human-readable rendering of a mapping key for find_cycles() paths.
std::string render_key(const svalue_t* key) {
switch (key->type) {
case T_NUMBER:
return std::to_string(key->u.number);
case T_REAL: {
char buf[64];
snprintf(buf, sizeof(buf), "%g", key->u.real);
return buf;
}
case T_STRING: {
size_t len = SVALUE_STRLEN(key);
if (len > 32) {
len = u8_truncate(reinterpret_cast<const uint8_t*>(key->u.string), 32);
}
return "\"" + std::string(key->u.string, len) + "\"";
}
case T_OBJECT:
return std::string("OBJ(") + key->u.ob->obname + ")";
case T_ARRAY:
return "<array>";
case T_CLASS:
return "<class>";
case T_MAPPING:
return "<mapping>";
case T_FUNCTION:
return "<function>";
default:
return "<...>";
}
}
Frame make_frame(unsigned short type, void* ptr) {
Frame f{};
f.type = type;
f.u.ptr = ptr;
if (type == T_MAPPING) {
f.node = reinterpret_cast<mapping_t*>(ptr)->table[0];
}
return f;
}
void cycle_walk(svalue_t* root, WalkMode mode, WalkResult* res) {
void* rptr = compound_ptr(root);
if (rptr == nullptr) {
return;
}
std::unordered_map<void*, char> color;
std::vector<Frame> stack;
std::vector<std::string> labels; // edge label per open frame below the root
color[rptr] = COLOR_GREY;
stack.push_back(make_frame(root->type, rptr));
// Examine one outgoing edge of the frame on top of the stack. `slot` is
// null only for the funptr->args edge (kind SLOT_FP_ARGS), which is not an
// svalue slot; it CAN be a back-edge target, because bind() shares the
// args array between the old and the new funptr (f_bind in
// packages/core/efuns_main.cc bumps args->ref instead of copying).
auto handle_edge = [&](unsigned short ttype, void* tptr, svalue_t* slot, SlotKind kind,
std::string&& label) {
auto ins = color.try_emplace(tptr, COLOR_GREY);
if (ins.second) { // white: tree edge, descend
Frame child = make_frame(ttype, tptr);
stack.push_back(child); // may invalidate refs into `stack`
if (mode == WALK_FIND) {
labels.push_back(std::move(label));
}
return;
}
if (ins.first->second == COLOR_GREY) { // back-edge: a cycle
res->found = true;
if (mode == WALK_FIND) {
std::string path;
for (const auto& l : labels) {
path += l;
}
path += label;
res->paths.push_back(std::move(path));
} else if (mode == WALK_BREAK) {
PendingFix fix;
fix.kind = kind;
fix.slot = slot;
// Hold a reference on the slot's container (for SLOT_FP_ARGS, the
// owning funptr) so the post-pass can touch it no matter what
// earlier fixes deallocated.
const Frame& owner = stack.back();
svalue_t tmp;
tmp.type = owner.type;
tmp.subtype = 0;
tmp.u.arr = reinterpret_cast<array_t*>(owner.u.ptr);
assign_svalue_no_free(&fix.container, &tmp);
res->fixes.push_back(fix);
}
}
// black: already fully explored, nothing new reachable
};
while (!stack.empty()) {
if (mode == WALK_DETECT && res->found) {
return;
}
Frame& f = stack.back();
svalue_t* slot = nullptr;
SlotKind kind = SLOT_ITEM;
std::string label;
bool have_edge = false;
switch (f.type) {
case T_ARRAY:
case T_CLASS: {
array_t* arr = f.u.arr;
if (f.idx < arr->size) {
slot = &arr->item[f.idx];
kind = SLOT_ITEM;
if (mode == WALK_FIND) {
label = (f.type == T_ARRAY) ? "[" + std::to_string(f.idx) + "]"
: "." + std::to_string(f.idx);
}
f.idx++;
have_edge = true;
}
break;
}
case T_MAPPING: {
mapping_t* map = f.u.map;
// table_size is a mask: valid buckets are 0 .. table_size inclusive
while (f.node == nullptr && f.idx < static_cast<int>(map->table_size)) {
f.idx++;
f.node = map->table[f.idx];
}
if (f.node != nullptr) {
if (f.phase == 0) {
slot = &f.node->values[0];
kind = SLOT_MAP_KEY;
if (mode == WALK_FIND) {
label = "[key " + render_key(slot) + "]";
}
f.phase = 1;
} else {
slot = &f.node->values[1];
kind = SLOT_MAP_VALUE;
if (mode == WALK_FIND) {
label = "[" + render_key(&f.node->values[0]) + "]";
}
f.node = f.node->next;
f.phase = 0;
}
have_edge = true;
}
break;
}
case T_FUNCTION: {
funptr_t* fp = f.u.fp;
if (f.idx == 0) {
f.idx = 1;
if (fp->hdr.args != nullptr) {
handle_edge(T_ARRAY, reinterpret_cast<void*>(fp->hdr.args), nullptr, SLOT_FP_ARGS,
mode == WALK_FIND ? std::string("(args)") : std::string());
continue; // `f` may be a stale reference now
}
}
break;
}
}
if (!have_edge) { // frame exhausted: close it
color[f.u.ptr] = COLOR_BLACK;
stack.pop_back();
if (mode == WALK_FIND && !stack.empty()) {
labels.pop_back(); // the root frame never pushed a label
}
continue;
}
void* tptr = compound_ptr(slot);
if (tptr != nullptr) {
handle_edge(slot->type, tptr, slot, kind, std::move(label));
// `f` may be a stale reference now; loop back around
}
}
}
} // namespace
#endif // any of the three efuns
#ifdef F_HAS_CYCLE
void f_has_cycle() {
WalkResult res;
cycle_walk(sp, WALK_DETECT, &res);
free_svalue(sp, "f_has_cycle");
put_number(res.found ? 1 : 0);
}
#endif
#ifdef F_FIND_CYCLES
void f_find_cycles() {
WalkResult res;
cycle_walk(sp, WALK_FIND, &res);
// May error("Illegal array size") if there are more back-edges than
// max_array_size; at this point nothing is held, so that unwinds cleanly
// (the std::vectors are reclaimed by their destructors).
array_t* ret = allocate_empty_array(static_cast<int>(res.paths.size()));
for (size_t i = 0; i < res.paths.size(); i++) {
ret->item[i].type = T_STRING;
ret->item[i].subtype = STRING_MALLOC;
ret->item[i].u.string = string_copy(res.paths[i].c_str(), "f_find_cycles");
}
free_svalue(sp, "f_find_cycles");
put_array(ret);
}
#endif
#ifdef F_BREAK_CYCLES
void f_break_cycles() {
WalkResult res;
cycle_walk(sp, WALK_BREAK, &res);
// The temporary container references taken during the walk are released
// by RAII, not a trailing loop: allocate_array() below can error() (LPC
// can shrink __MAX_ARRAY_SIZE__ at runtime via set_config(), making a
// same-size replacement illegal), and an unwind that skipped a manual
// release loop would leak every held reference (AGENTS.md section 4).
struct ContainerRelease {
std::vector<PendingFix>* fixes;
~ContainerRelease() {
for (auto& fix : *fixes) {
free_svalue(&fix.container, "f_break_cycles");
}
}
} release_guard{&res.fixes};
// Apply the recorded fixes. Order matters:
//
// 1. Overwrite item/value back-edge slots with 0 first, and detach the
// args list of any funptr whose args EDGE is the back-edge (possible
// via bind()'s args sharing) -- replaced with a zero-filled list of
// the same size so the funptr keeps its call arity. Neither can ever
// cascade a deallocation: every back-edge target is an ancestor on the
// walk's tree path, and that path -- made of tree edges, which we never
// break -- anchors it to the root the caller still holds on the VM
// stack.
LPC_INT broken = 0;
for (auto& fix : res.fixes) {
if (fix.kind == SLOT_FP_ARGS) {
funptr_t* fp = fix.container.u.fp;
array_t* old = fp->hdr.args;
if (old != nullptr) {
fp->hdr.args = allocate_array(old->size);
free_array(old);
}
broken++;
} else if (fix.kind != SLOT_MAP_KEY) {
free_svalue(fix.slot, "f_break_cycles");
*fix.slot = const0;
broken++;
}
}
// 2. Then delete mapping nodes whose KEY is the back-edge. Deleting a node
// frees its value subtree, which CAN cascade; every other pending fix
// holds its own reference on its container (taken during the walk), so
// the deletions are order-independent and never touch freed memory.
// (Same caveat as map_delete(): a `foreach (k, ref v in m)` loop
// variable in an OUTER frame aimed at the deleted node's value slot is
// left dangling -- a pre-existing hazard of node deletion during an
// unlocked mapping foreach, not specific to this efun.)
for (auto& fix : res.fixes) {
if (fix.kind == SLOT_MAP_KEY) {
mapping_delete(fix.container.u.map, fix.slot);
broken++;
}
}
free_svalue(sp, "f_break_cycles");
put_number(broken);
}
#endif

View file

@ -38,6 +38,10 @@
#include "packages/jsbridge/jsbridge.h"
#endif
#include <functional>
#include <unordered_map>
#include <vector>
#if (defined(DEBUGMALLOC) && defined(DEBUGMALLOC_EXTENSIONS))
void mark_svalue(struct svalue_t*);
@ -1070,6 +1074,20 @@ void check_all_blocks(int flag) {
}
}
if (!(flag & 2)) {
// A detached reference loop is invisible to the ref-count comparison
// above (a cycle is perfectly self-consistent), so hunt for it
// separately -- skippable via flag bit 2 (value 4) since it is a full
// extra pass over every compound block. Report only; collection is the
// mudlib's explicit call via find_orphaned_cycles(1).
if (!(flag & 4)) {
int orphans = md_scan_orphaned_cycles(0, &out);
if (orphans) {
outbuf_addv(&out,
"WARNING: %d unreachable data block(s) kept alive only by "
"reference loop(s); reclaim with find_orphaned_cycles(1)\n",
orphans);
}
}
outbuf_push(&out);
} else {
FREE_MSTR(out.buffer);
@ -1077,4 +1095,268 @@ void check_all_blocks(int flag) {
}
}
/*
* Orphaned reference-loop detection and collection, via trial deletion
* (the same idea CPython's gc uses):
*
* For every cycle-capable data block (array, class, mapping, funptr),
* count how many of its references come from OTHER data blocks
* ("internal"). external = ref - internal. A block with external > 0 is
* held by something that is not plain data -- an object's variables, the
* VM stack, a call_out, a C++-side holder -- and is therefore a live
* root; liveness then propagates along data edges. Whatever remains is
* reachable only from itself: garbage that pure reference counting can
* never reclaim, which (in a finite graph) always contains at least one
* reference loop.
*
* Because the verdict is computed from real ref counts, there is no root
* enumeration to get wrong: any holder that took a legitimate reference --
* including package internals invisible to the mark phase -- shows up as an
* external ref and keeps its data alive.
*
* Collection mirrors what free-ing the loop by hand would do, in an order
* that can never touch freed memory:
* 1. take a reference on every dead block (so nothing deallocates early),
* 2. sever: clear every dead block's child slots (releasing strings,
* objects, buffers, and live values normally, and unlinking the dead
* blocks from each other; a dead funptr just drops its args array),
* 3. release the held references -- each dead block is now at ref 1 and
* deallocates cleanly with nothing left to cascade into.
*/
int md_scan_orphaned_cycles(int collect, outbuffer_t* ob) {
struct Cand {
int tag;
int internal = 0;
bool live = false;
};
std::unordered_map<void*, Cand> cands;
cands.reserve(blocks[TAG_ARRAY & 0xff] + blocks[TAG_CLASS & 0xff] +
blocks[TAG_MAPPING & 0xff] + blocks[TAG_FUNP & 0xff]);
for (int hsh = 0; hsh < MD_TABLE_SIZE; hsh++) {
for (md_node_t* entry = table[hsh]; entry; entry = entry->next) {
switch (entry->tag) {
case TAG_ARRAY:
case TAG_CLASS:
cands[NODET_TO_PTR(entry, void*)] = Cand{entry->tag};
break;
case TAG_MAPPING:
cands[NODET_TO_PTR(entry, void*)] = Cand{entry->tag};
break;
case TAG_FUNP:
cands[NODET_TO_PTR(entry, void*)] = Cand{entry->tag};
break;
}
}
}
auto ref_of = [](void* p, int tag) -> LPC_INT {
switch (tag) {
case TAG_ARRAY:
case TAG_CLASS:
return reinterpret_cast<array_t*>(p)->ref;
case TAG_MAPPING:
return reinterpret_cast<mapping_t*>(p)->ref;
case TAG_FUNP:
return reinterpret_cast<funptr_t*>(p)->hdr.ref;
}
return 0;
};
auto data_child = [](svalue_t* sv) -> void* {
switch (sv->type) {
case T_ARRAY:
case T_CLASS:
return reinterpret_cast<void*>(sv->u.arr);
case T_MAPPING:
return reinterpret_cast<void*>(sv->u.map);
case T_FUNCTION:
return reinterpret_cast<void*>(sv->u.fp);
}
return nullptr;
};
// cb receives each data-block pointer this block holds a reference to.
// (Generic lambda so the per-element callback inlines -- this runs over
// every slot of every compound block in the heap, twice.)
// NOTE: this edge set (array/class items, mapping keys AND values,
// fp->hdr.args; objects are leaves) must stay in sync with the walker in
// src/packages/contrib/cycles.cc (cycle_walk), or has_cycle()/
// break_cycles() and this scan will disagree about what a loop is.
auto each_child = [&](void* p, int tag, auto&& cb) {
switch (tag) {
case TAG_ARRAY:
case TAG_CLASS: {
auto* arr = reinterpret_cast<array_t*>(p);
for (int i = 0; i < arr->size; i++) {
if (void* c = data_child(&arr->item[i])) {
cb(c);
}
}
break;
}
case TAG_MAPPING: {
auto* map = reinterpret_cast<mapping_t*>(p);
for (int i = 0; i <= static_cast<int>(map->table_size); i++) {
for (mapping_node_t* node = map->table[i]; node; node = node->next) {
if (void* c = data_child(&node->values[0])) {
cb(c);
}
if (void* c = data_child(&node->values[1])) {
cb(c);
}
}
}
break;
}
case TAG_FUNP: {
auto* fp = reinterpret_cast<funptr_t*>(p);
if (fp->hdr.args) {
cb(reinterpret_cast<void*>(fp->hdr.args));
}
break;
}
}
};
// internal = references held by other data blocks
for (auto& kv : cands) {
each_child(kv.first, kv.second.tag, [&](void* c) {
auto it = cands.find(c);
if (it != cands.end()) {
it->second.internal++;
}
});
}
// seeds: any block some non-data holder references (conservatively
// including anything whose counts look inconsistent), then propagate
std::vector<std::pair<void*, int>> work; // (block, tag) -- avoids a re-lookup per pop
for (auto& kv : cands) {
if (ref_of(kv.first, kv.second.tag) != kv.second.internal) {
kv.second.live = true;
work.emplace_back(kv.first, kv.second.tag);
}
}
while (!work.empty()) {
auto [p, tag] = work.back();
work.pop_back();
each_child(p, tag, [&](void* c) {
auto it = cands.find(c);
if (it != cands.end() && !it->second.live) {
it->second.live = true;
work.emplace_back(c, it->second.tag);
}
});
}
int dead = 0, n_arr = 0, n_cls = 0, n_map = 0, n_fp = 0;
for (auto& kv : cands) {
if (!kv.second.live) {
dead++;
switch (kv.second.tag) {
case TAG_ARRAY:
n_arr++;
break;
case TAG_CLASS:
n_cls++;
break;
case TAG_MAPPING:
n_map++;
break;
case TAG_FUNP:
n_fp++;
break;
}
}
}
if (dead && ob) {
outbuf_addv(ob, "orphaned by reference loops: %d array(s), %d class(es), %d mapping(s), %d function pointer(s)\n",
n_arr, n_cls, n_map, n_fp);
}
if (dead && collect) {
// 1. hold
for (auto& kv : cands) {
if (kv.second.live) {
continue;
}
switch (kv.second.tag) {
case TAG_ARRAY:
case TAG_CLASS:
reinterpret_cast<array_t*>(kv.first)->ref++;
break;
case TAG_MAPPING:
reinterpret_cast<mapping_t*>(kv.first)->ref++;
break;
case TAG_FUNP:
reinterpret_cast<funptr_t*>(kv.first)->hdr.ref++;
break;
}
}
// 2. sever
for (auto& kv : cands) {
if (kv.second.live) {
continue;
}
switch (kv.second.tag) {
case TAG_ARRAY:
case TAG_CLASS: {
auto* arr = reinterpret_cast<array_t*>(kv.first);
for (int i = 0; i < arr->size; i++) {
free_svalue(&arr->item[i], "collect_cycles");
arr->item[i] = const0;
}
break;
}
case TAG_MAPPING: {
// The mapping is garbage; nobody can look anything up in it, so
// zeroing keys in place (hash invariants be damned) is fine --
// dealloc_mapping just walks the table.
auto* map = reinterpret_cast<mapping_t*>(kv.first);
for (int i = 0; i <= static_cast<int>(map->table_size); i++) {
for (mapping_node_t* node = map->table[i]; node; node = node->next) {
free_svalue(&node->values[0], "collect_cycles");
node->values[0] = const0;
free_svalue(&node->values[1], "collect_cycles");
node->values[1] = const0;
}
}
break;
}
case TAG_FUNP: {
auto* fp = reinterpret_cast<funptr_t*>(kv.first);
if (fp->hdr.args) {
free_array(fp->hdr.args);
fp->hdr.args = nullptr;
}
break;
}
}
}
// 3. release
for (auto& kv : cands) {
if (kv.second.live) {
continue;
}
switch (kv.second.tag) {
case TAG_ARRAY:
free_array(reinterpret_cast<array_t*>(kv.first));
break;
case TAG_CLASS:
free_class(reinterpret_cast<array_t*>(kv.first));
break;
case TAG_MAPPING:
free_mapping(reinterpret_cast<mapping_t*>(kv.first));
break;
case TAG_FUNP:
free_funp(reinterpret_cast<funptr_t*>(kv.first));
break;
}
}
}
return dead;
}
#endif /* DEBUGMALLOC_EXTENSIONS */

View file

@ -246,6 +246,14 @@ void f_set_malloc_mask() { set_malloc_mask((sp--)->u.number); }
#ifdef F_CHECK_MEMORY
void f_check_memory() { check_all_blocks((sp--)->u.number); }
#endif
#ifdef F_FIND_ORPHANED_CYCLES
void f_find_orphaned_cycles() {
// any nonzero LPC int means collect (don't truncate the 64-bit argument)
int const collect = (sp--)->u.number != 0;
push_number(md_scan_orphaned_cycles(collect, nullptr));
}
#endif
#endif /* (defined(DEBUGMALLOC) && \
* defined(DEBUGMALLOC_EXTENSIONS)) */

View file

@ -18,6 +18,9 @@
string debugmalloc(string, int default: 0);
void set_malloc_mask(int);
string check_memory(int default: 0);
/* count (and with arg 1, reclaim) data blocks that are unreachable because
only a reference loop keeps them alive -- see checkmemory.cc */
int find_orphaned_cycles(int default: 0);
#endif
string dump_stralloc(string);
#ifdef DEBUG

View file

@ -41,8 +41,15 @@ namespace fs = ghc::filesystem;
#include "packages/sockets/socket_efuns.h" // for check_valid_path
#endif
#define too_deep_save_error() \
error("Mappings and/or arrays nested too deep (%d) for save_object\n", MAX_SAVE_SVALUE_DEPTH);
// Only point at has_cycle() when the contrib package actually provides it.
#ifdef PACKAGE_CONTRIB
#define TOO_DEEP_SAVE_HINT " -- possibly a reference loop; see has_cycle()"
#else
#define TOO_DEEP_SAVE_HINT " -- possibly a reference loop"
#endif
#define too_deep_save_error() \
error("Mappings and/or arrays nested too deep (%d) for save_object" TOO_DEEP_SAVE_HINT "\n", \
MAX_SAVE_SVALUE_DEPTH);
object_t* previous_ob;

View file

@ -0,0 +1,144 @@
// break_cycles(mixed): clears every reference loop in the value, in place,
// by zeroing the back-edge slots (deleting the node when the back-edge is a
// mapping KEY). Returns the number of edges broken. Everything that is not
// part of a loop -- including DAG sharing -- must be left untouched, and the
// value must afterwards be save/copy/print-safe again.
class Node {
mixed data;
mixed next;
}
private void run_checks() {
#if efun_defined(break_cycles)
mixed *a, *ring1, *ring2, *ring3, *shared, *dag;
mapping m1, m2;
class Node n1, n2;
function f, g;
object clone;
// non-compound and acyclic values: nothing to do
ASSERT_EQ(0, break_cycles(42));
ASSERT_EQ(0, break_cycles(({ 1, ({ 2 }), ([ "k": 3 ]) })));
// self-referential array
a = ({ "keep", 0 });
a[1] = a;
ASSERT_EQ(1, break_cycles(a));
ASSERT_EQ("keep", a[0]); // non-loop content untouched
ASSERT_EQ(0, a[1]); // the back-edge is now 0
ASSERT_EQ(0, has_cycle(a));
#if efun_defined(refs)
ASSERT_EQ(1, refs(a)); // only the local variable holds it
#endif
ASSERT_EQ(0, catch(save_variable(a))); // saving works again
ASSERT_EQ(0, catch(copy(a))); // deep copy works again
// DAG sharing is preserved, not treated as a loop
shared = ({ 1 });
dag = ({ shared, shared });
ASSERT_EQ(0, break_cycles(dag));
ASSERT(dag[0] == shared && dag[1] == shared); // identity intact
// mutual mapping cycle: exactly one edge cut, the rest intact
m1 = ([ "name": "m1" ]);
m2 = ([ "name": "m2" ]);
m1["peer"] = m2;
m2["peer"] = m1;
ASSERT_EQ(1, break_cycles(m1));
ASSERT_EQ(0, has_cycle(m1));
ASSERT_EQ("m1", m1["name"]);
ASSERT_EQ("m2", m2["name"]);
// one of the two peer edges survives, the other is 0
ASSERT((m1["peer"] == m2 && !m2["peer"]) || (m2["peer"] == m1 && !m1["peer"]));
// ring of three arrays: one cut un-loops the whole ring
ring1 = ({ 0 });
ring2 = ({ 0 });
ring3 = ({ 0 });
ring1[0] = ring2;
ring2[0] = ring3;
ring3[0] = ring1;
ASSERT_EQ(1, break_cycles(ring1));
ASSERT_EQ(0, has_cycle(ring1));
// circular class list
n1 = new(class Node, data: 1);
n2 = new(class Node, data: 2);
n1.next = n2;
n2.next = n1;
ASSERT_EQ(1, break_cycles(n1));
ASSERT_EQ(0, has_cycle(n1));
ASSERT(n1.next == n2); // tree edge kept, back-edge cut
ASSERT_EQ(0, n2.next);
// cycle closed in mapping-KEY position: the node is deleted
m1 = ([]);
m2 = ([]);
m1[m2] = "v1";
m2[m1] = "v2";
ASSERT_EQ(1, break_cycles(m1));
ASSERT_EQ(0, has_cycle(m1));
ASSERT_EQ(1, sizeof(m1) + sizeof(m2)); // exactly one node was deleted
// cycle through a function pointer's captured arguments: the captured
// slot inside the args list is zeroed, the funptr itself survives
a = ({ 0 });
f = (: member_array, 0, a :);
a[0] = f;
ASSERT_EQ(1, break_cycles(a));
ASSERT_EQ(0, has_cycle(a));
ASSERT(functionp(a[0]));
// bind() to a DIFFERENT owner creates a new funptr that SHARES the old
// one's args array, so a loop can close on the funptr->args edge itself
// (no svalue slot to zero): the bound funptr's args list is detached and
// replaced with a zero-filled one instead. Regression: this used to be
// silently unbreakable (break_cycles() returned 0, has_cycle() stayed 1).
// (bind() to the SAME owner is a no-op that returns the original funptr,
// which is why the clone is needed to construct the shared-args pair.)
a = ({ 0 });
f = (: member_array, 0, a :); // f.args = ({ 0, a })
clone = new(__FILE__);
g = bind(f, clone); // g is a new funptr sharing f's args
ASSERT(g != f);
a[0] = g; // args -> a -> g -> args : the loop
ASSERT_EQ(1, has_cycle(f));
ASSERT_EQ(1, break_cycles(f));
ASSERT_EQ(0, has_cycle(f));
ASSERT_EQ(0, has_cycle(g));
ASSERT(functionp(a[0])); // g itself survives, args detached
destruct(clone);
// values containing DESTRUCTED objects: walked as leaves and rendered
// in paths without touching freed memory, freed normally on break
clone = new(__FILE__);
m1 = ([ clone: ({ 0 }) ]);
m1[clone][0] = m1; // loop through the value under an object key
destruct(clone);
ASSERT_EQ(1, has_cycle(m1));
#if efun_defined(find_cycles)
ASSERT_EQ(1, sizeof(find_cycles(m1))); // renders the (dead) object key
#endif
ASSERT_EQ(1, break_cycles(m1));
ASSERT_EQ(0, has_cycle(m1));
// idempotent: nothing left to break
ASSERT_EQ(0, break_cycles(a));
ASSERT_EQ(0, break_cycles(m1));
#endif
ASSERT(1);
}
void do_tests() {
// unconditional teardown (AGENTS.md section 7): if anything above errors
// uncaught mid-test, locally-built cycles unwind into orphans that the
// harness's post-file check_memory() now hard-fails on -- collect them
// so one regression stays one [ FAILED ] entry instead of aborting the
// whole randomized run
string err = catch(run_checks());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
}

View file

@ -0,0 +1,73 @@
// find_cycles(mixed): index paths (one string per back-edge) locating every
// slot that closes a reference loop. Array/class paths are deterministic;
// mapping paths depend on internal table order, so those are checked by
// count and prefix only.
class Node {
mixed data;
mixed next;
}
private void run_checks() {
#if efun_defined(find_cycles)
mixed *a, *b, *two;
mapping m;
class Node n;
string *paths;
// acyclic values report nothing
ASSERT_EQ(({}), find_cycles(0));
ASSERT_EQ(({}), find_cycles(({ 1, ({ 2 }) })));
// self-referential array: the back-edge is the slot itself
a = ({ "x", 0 });
a[1] = a;
ASSERT_EQ(({ "[1]" }), find_cycles(a));
a[1] = 0; // break before reassigning, or the old array is leaked
// nested: path walks through the outer array
b = ({ ({ 0 }) });
b[0][0] = b;
ASSERT_EQ(({ "[0][0]" }), find_cycles(b));
b[0][0] = 0;
// two independent loops: one path each
a = ({ 0 });
a[0] = a;
b = ({ 0 });
b[0] = b;
two = ({ a, b });
ASSERT_EQ(({ "[0][0]", "[1][0]" }), find_cycles(two));
// class ring: field index in declaration order (next == field 1)
n = new(class Node, data: 1);
n.next = n;
ASSERT_EQ(({ ".1" }), find_cycles(n));
// mapping value cycle: exactly one back-edge, key rendered in the path
m = ([]);
m["self"] = m;
paths = find_cycles(m);
ASSERT_EQ(({ "[\"self\"]" }), paths);
// break everything before returning
a[0] = 0;
b[0] = 0;
n.next = 0;
map_delete(m, "self");
#endif
ASSERT(1);
}
void do_tests() {
// unconditional teardown (AGENTS.md section 7): if anything above errors
// uncaught mid-test, locally-built cycles unwind into orphans that the
// harness's post-file check_memory() now hard-fails on -- collect them
// so one regression stays one [ FAILED ] entry instead of aborting the
// whole randomized run
string err = catch(run_checks());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
}

View file

@ -0,0 +1,111 @@
// find_orphaned_cycles(int collect): Debug-build (DEBUGMALLOC_EXTENSIONS)
// garbage detector for reference loops that have already been dropped --
// data blocks kept alive only by a loop, unreachable from any variable, so
// no LPC-level tool (refs(), break_cycles()) can reach them anymore.
// Detection is trial deletion: a block whose every reference comes from
// other data blocks, with no path from an externally-held block, is
// garbage. With collect=1 the garbage is reclaimed safely.
//
// Counts are asserted relative to a baseline snapshot so the test is
// insensitive to whatever the surrounding suite state holds. If this test
// itself failed to clean up, the harness's post-file check_memory() would
// now also flag the orphans ("unreachable data block(s)") -- that report
// line is new alongside this efun.
class Ring {
mixed next;
}
void make_orphans() {
// in a helper so its locals are provably off the stack when we scan
mixed *a;
mapping m1, m2;
function f;
class Ring c1, c2;
// self-referential array, dropped: 1 orphaned block. The buffer riding
// inside is NOT cycle-capable (not counted), but collection must release
// it via the sever pass -- a buffer leak here trips the harness's
// post-file ref-count gate.
a = ({ 0, allocate_buffer(64) });
a[0] = a;
a = 0;
// mutual mapping pair (value edges), dropped: 2 orphaned blocks
m1 = ([]);
m2 = ([]);
m1["peer"] = m2;
m2["peer"] = m1;
m1 = 0;
m2 = 0;
// mutual mapping pair closed in KEY position, dropped: 2 orphaned
// blocks -- exercises the collector's key-slot sever path (zeroing a
// node's key in place), which break_cycles() never uses
m1 = ([]);
m2 = ([]);
m1[m2] = "v1";
m2[m1] = "v2";
m1 = 0;
m2 = 0;
// circular class list, dropped: 2 orphaned blocks (TAG_CLASS candidate,
// hold, sever, and free_class release paths)
c1 = new(class Ring);
c2 = new(class Ring);
c1.next = c2;
c2.next = c1;
c1 = 0;
c2 = 0;
// array -> funptr -> captured-args -> array ring, dropped: 3 blocks
// (the array, the function pointer, and its args array)
a = ({ 0 });
f = (: member_array, 0, a :);
a[0] = f;
a = 0;
f = 0;
}
private void run_checks() {
#if efun_defined(find_orphaned_cycles)
int base, n;
mixed *live;
base = find_orphaned_cycles(0); // whatever the suite already leaked
make_orphans();
n = find_orphaned_cycles(0); // detect only, nothing reclaimed
ASSERT_EQ(base + 10, n);
ASSERT_EQ(base + 10, find_orphaned_cycles(0)); // detection is idempotent
// a loop still reachable from a variable is NOT orphaned garbage
live = ({ 0 });
live[0] = live;
ASSERT_EQ(base + 10, find_orphaned_cycles(0));
// collect: our 6 blocks (plus any pre-existing orphans) are reclaimed
ASSERT_EQ(base + 10, find_orphaned_cycles(1));
ASSERT_EQ(0, find_orphaned_cycles(0));
// the reachable loop survived the collection intact
ASSERT(live[0] == live);
#if efun_defined(break_cycles)
break_cycles(live);
#else
live[0] = 0;
#endif
#endif
ASSERT(1);
}
void do_tests() {
// unconditional teardown (AGENTS.md section 7): an uncaught error after
// make_orphans() would otherwise strand the orphans, and the harness's
// post-file check_memory() now hard-fails on them
string err = catch(run_checks());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
}

View file

@ -0,0 +1,97 @@
// has_cycle(mixed): 1 if the value's reference graph contains a loop.
// The walker is iterative, so unlike save_variable()/copy() it has no
// depth cap -- deep acyclic nesting must scan clean, not error.
class Node {
mixed data;
mixed next;
}
private void run_checks() {
#if efun_defined(has_cycle)
mixed *a, *deep, *shared;
mapping m1, m2;
class Node n1, n2;
function f;
int i;
// leaves and acyclic structures
ASSERT_EQ(0, has_cycle(0));
ASSERT_EQ(0, has_cycle("string"));
ASSERT_EQ(0, has_cycle(({ 1, ({ 2, ({ 3 }) }), ([ "k": ({ 4 }) ]) })));
// DAG sharing (the same array referenced twice) is NOT a cycle
shared = ({ 1 });
ASSERT_EQ(0, has_cycle(({ shared, shared, ([ "k": shared ]) })));
// self-referential array, directly and one level down
a = ({ 0 });
a[0] = a;
ASSERT_EQ(1, has_cycle(a));
ASSERT_EQ(1, has_cycle(({ "wrapper", a })));
a[0] = 0;
ASSERT_EQ(0, has_cycle(a));
// mutual mapping cycle
m1 = ([]);
m2 = ([]);
m1["peer"] = m2;
m2["peer"] = m1;
ASSERT_EQ(1, has_cycle(m1));
ASSERT_EQ(1, has_cycle(m2));
map_delete(m1, "peer");
ASSERT_EQ(0, has_cycle(m1));
ASSERT_EQ(0, has_cycle(m2));
// cycle where a mapping is used as a KEY of another mapping
m1 = ([]);
m2 = ([]);
m1[m2] = 1;
m2[m1] = 1;
ASSERT_EQ(1, has_cycle(m1));
map_delete(m1, m2);
ASSERT_EQ(0, has_cycle(m2));
map_delete(m2, m1);
// circular class list
n1 = new(class Node, data: 1);
n2 = new(class Node, data: 2);
n1.next = n2;
n2.next = n1;
ASSERT_EQ(1, has_cycle(n1));
n2.next = 0;
ASSERT_EQ(0, has_cycle(n1));
// cycle through a function pointer's captured arguments
a = ({ 0 });
f = (: member_array, 0, a :);
a[0] = f;
ASSERT_EQ(1, has_cycle(a));
ASSERT_EQ(1, has_cycle(f));
a[0] = 0;
ASSERT_EQ(0, has_cycle(f));
// deep acyclic nesting far beyond MAX_SAVE_SVALUE_DEPTH: the iterative
// walker must return 0 cleanly where save_variable() errors out
deep = ({ 0 });
for (i = 0; i < 5000; i++) {
deep = ({ deep });
}
ASSERT_EQ(0, has_cycle(deep));
ASSERT(catch(save_variable(deep)));
#endif
ASSERT(1);
}
void do_tests() {
// unconditional teardown (AGENTS.md section 7): if anything above errors
// uncaught mid-test, locally-built cycles unwind into orphans that the
// harness's post-file check_memory() now hard-fails on -- collect them
// so one regression stays one [ FAILED ] entry instead of aborting the
// whole randomized run
string err = catch(run_checks());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
}

View file

@ -0,0 +1,128 @@
// Reference loops (cyclic data structures) and the reference-counting VM.
//
// FluffOS manages arrays / mappings / classes / buffers / function pointers
// purely by reference counting (free_svalue -> dealloc_* at ref 0, see
// src/vm/internal/base/svalue.cc); there is NO cycle collector. A structure
// that reaches itself keeps its own ref >= 1 forever, so the moment the last
// OUTSIDE reference is dropped the whole loop becomes unreachable-but-alive:
// memory that can never be reclaimed until the driver shuts down. The leak is
// even invisible to the Debug ref-count checker (check_memory), because a
// detached cycle is perfectly self-consistent -- every internal reference is
// marked by another member of the loop.
//
// This test pins what IS guaranteed around cycles:
// 1. building and traversing them is legal (assignment is pointer copy),
// 2. refs() exposes the extra self-reference (the leak-detection tool),
// 3. the recursion-guarded consumers survive them: sprintf("%O") truncates
// with "...", save_variable()/save_object() and copy() error cleanly at
// MAX_SAVE_SVALUE_DEPTH instead of overflowing the C stack,
// 4. destruct() breaks any loop that passes through an object's global
// variables (destruct2() zeroes the variable block for exactly this
// reason -- see src/vm/internal/simulate.cc), and
// 5. explicitly overwriting the back-reference really releases the loop.
//
// Every cycle built below is broken before the test returns: that is the
// documented prevention pattern, and it keeps Debug/ASan suite runs
// leak-free.
#include <lpctypes.h>
class Node {
mixed data;
mixed next;
}
// fixture state for the destruct demo (only used on clones of this file)
mixed stash;
void set_stash(mixed v) { stash = v; }
mixed query_stash() { return stash; }
private void run_checks() {
mixed *a, *plain;
mapping m1, m2;
class Node n1, n2;
mapping outer;
object ob;
string err, dump;
// --- 1. cycles are legal to build and traverse ------------------------
a = ({ 0 });
a[0] = a; // self-referential array
ASSERT(a[0] == a); // == on arrays is pointer identity
ASSERT(a[0][0][0] == a); // traversal never ends, one hop at a time
m1 = ([]);
m2 = ([]);
m1["peer"] = m2; // mutual mapping cycle
m2["peer"] = m1;
ASSERT(m1["peer"]["peer"] == m1);
n1 = new(class Node, data: 1);
n2 = new(class Node, data: 2);
n1.next = n2; // two-node circular list
n2.next = n1;
ASSERT(n1.next.next == n1);
// --- 2. refs() shows the extra self-reference -------------------------
#if efun_defined(refs)
plain = ({ 0 });
ASSERT_EQ(1, refs(plain)); // held only by the local variable
ASSERT_EQ(2, refs(a)); // local variable + the cycle's own ref:
// dropping `a` leaves ref at 1 forever
#endif
// --- 3. recursion-guarded consumers survive cycles ---------------------
// sprintf("%O") truncates self-referential structures with "..."
dump = sprintf("%O", a);
ASSERT(strsrch(dump, "...") != -1);
dump = sprintf("%O", m1);
ASSERT(strsrch(dump, "...") != -1);
// save_variable() hits the MAX_SAVE_SVALUE_DEPTH cap and errors cleanly
err = catch(save_variable(a));
ASSERT(err && strsrch(err, "too deep") != -1);
err = catch(save_variable(m1));
ASSERT(err && strsrch(err, "too deep") != -1);
// copy() (deep copy) also errors at the depth cap instead of recursing
// the C stack into the ground
err = catch(copy(a));
ASSERT(err && strsrch(err, "too deep") != -1);
err = catch(copy(n1));
ASSERT(err && strsrch(err, "too deep") != -1);
// --- 4. destruct() breaks loops through object variables ---------------
// The driver zeroes an object's global variables at destruct time
// precisely so that "an object with a variable pointing to itself would
// never be freed" cannot happen. A loop routed through an object is
// therefore reclaimable: destruct the object and the loop falls apart.
ob = new(__FILE__);
outer = ([ "owner": ob ]);
ob->set_stash(outer); // ob.stash -> outer -> ob : a cycle
ASSERT(ob->query_stash()["owner"] == ob);
destruct(ob);
ASSERT(!ob); // the object really died...
ASSERT(!outer["owner"]); // ...and the loop edge in `outer` is now 0
map_delete(outer, "owner");
// --- 5. prevention: break the loop before dropping it ------------------
a[0] = 0; // overwrite the back-reference
#if efun_defined(refs)
ASSERT_EQ(1, refs(a)); // only the local variable holds it again
#endif
map_delete(m1, "peer"); // one deleted edge un-loops both mappings
n2.next = 0; // same for the circular list
}
void do_tests() {
// unconditional teardown (AGENTS.md section 7): if anything above errors
// uncaught mid-test, locally-built cycles unwind into orphans that the
// harness's post-file check_memory() now hard-fails on -- collect them
// so one regression stays one [ FAILED ] entry instead of aborting the
// whole randomized run
string err = catch(run_checks());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
}

View file

@ -334,6 +334,7 @@ void test_encode_circular_references() {
m1 = (["key":"value"]);
m1["self"] = m1;
ASSERT(strsrch(json_encode(m1), "\"self\":null") != -1);
map_delete(m1, "self"); // break the loop before dropping m1, or it leaks
// Circular array reference. Note: `arr1 += ({arr1})` would NOT be
// circular (LPC array '+' builds a new array, so the element would
@ -351,6 +352,11 @@ void test_encode_circular_references() {
m1["ref"] = m2;
// Should encode with null for circular part
ASSERT(json_encode(m1));
// break the loops before the locals go out of scope -- a dropped
// reference loop is unreclaimable and now fails check_memory()
arr1[3] = 0;
map_delete(m1, "ref");
}
void test_roundtrip() {
@ -534,6 +540,7 @@ void test_performance_large_strings() {
}
void do_tests() {
string err;
if(!find_object("/std/json")) {
write("json not loaded, skipped");
return;
@ -577,7 +584,14 @@ void do_tests() {
test_encode_non_string_keys();
write("✓ Non-string key tests passed\n");
test_encode_circular_references();
// catch + unconditional collect: an uncaught error inside would strand
// the deliberately-cyclic fixtures as orphans, which the harness's
// post-file check_memory() now hard-fails on
err = catch(test_encode_circular_references());
#if efun_defined(find_orphaned_cycles)
find_orphaned_cycles(1);
#endif
if (err) error(err);
write("✓ Circular reference tests passed\n");
write("\n=== Round-trip Tests ===\n");