A class's variant-typed field holding a managed string is not released on finalize — general, not Exception-specific
Found while measuring [[bug-nilpy-caught-exception-objects-are-never-freed]]:
fixing the exception OBJECT's own leak still left a smaller, separate slope in
the ticket's int("notanumber") row. Isolating it:
class K:
def __init__(self, s):
self.msg = s # s is an UNANNOTATED param -> field is tyVariant
i = 0
while i < 640000:
e = K("x") # constructed, reassigned, previous K released each iter
i = i + 1
RSS grows linearly with the loop count. The SAME loop with self.n = 5 (an int,
still through an unannotated param, so still a variant field) is flat. So
this is not "K's instance is never released" — the instance clearly is (the
int-field case proves the class's own release path works) — it is specifically
a managed AnsiString held inside a variant-typed FIELD that survives.
Measured
| loop body | RSS at 40k | RSS at 640k | slope |
|---|---|---|---|
self.n = 5 (int, unannotated) |
284 KB | 284 KB | flat |
self.msg = s (str via unannotated param) |
1308 KB | 20124 KB | ~31 B/iter |
raise ValueError() (no message) |
1056 KB | 1056 KB | flat |
raise ValueError("x") |
1568 KB | 20256 KB | ~32 B/iter |
e = ValueError("x") (constructed, never raised) |
1308 KB | 19996 KB | ~32 B/iter |
The last three rows all funnel through the SAME thing: Exception.msg is a
plain AnsiString field, not a variant — so the leak is not confined to
variant-typed fields specifically, but Exception.msg's assignment
(msg := m; in Exception.Create) and a NilPy class's inferred-variant field
assignment may share a release gap, or may be two symptoms of the same root
cause in the class-finalize walker. Not yet distinguished — see below.
Where to look
compiler/rtti_emit.inc's ClassFieldNeedsFinal already includes a
tyVariant-field arm (kind 5, "a variant slot can hold a managed string, a
promo, or a refcounted NilPy object; the walker clears it via PXXVarClear on
finalize") and a tyClass-field arm (kind 6) for NilPy — so the LAYOUT
DESCRIPTOR looks complete on paper. PXXClassFinalize
(compiler/builtin/builtinheap.pas) walks it and calls PXXRecordRelease for
kinds 1-3; check whether kind 5 (variant) is actually wired into that walk at
runtime, and whether PXXVarClear/PyVarSlotClear correctly frees a
VT_STRING-tagged payload (as opposed to only decrementing a refcounted
OBJECT payload) — the fact that a plain-int variant field leaks nothing, but a
plain-string one does, points at the string case specifically inside whichever
walker actually runs.
Also check Exception.msg specifically: it is declared as a plain
AnsiString field (not a variant), which is FieldIsManaged territory, not
ClassFieldNeedsFinal's variant arm — a DIFFERENT code path from the K.msg
case above, so this ticket may turn out to be two separate bugs that happen to
look identical from the RSS slope alone. Measure both independently before
concluding they share a fix.
Why it matters
Every NilPy class with a string field (the ordinary shape — self.name = n,
any dataclass-like record) leaks that field's storage on every reassignment or
scope exit of an instance holding one, in a tight loop. This is a much wider
blast radius than the exception-specific ticket that surfaced it.
Gate
make test-nilpy + self-host byte-identical, plus an RSS-slope re-measurement
of the table above (40k/160k/640k, /usr/bin/time -f %M, per the debugging
playbook — a single run proves nothing): the string-field row must go flat,
matching the already-flat int-field row.
RESOLVED — verified no longer leaks (smart sweep, 2026-07-31 @a3dda2e3c)
Fresh fixedpoint compiler at HEAD. 640k iterations of e = K("hello") with an
unannotated (tyVariant) string field, e used each iter (total += len(e.msg),
output 3,200,000 confirms the loop ran and the construction was NOT dead-code-
eliminated): peak RSS flat at ~1MB. The variant field's managed string is
released on reassign/finalize now.
Fixed as part of the 2026-07-22/23 object-reclamation campaign (variant box lifetime + managed-string reclamation, feature-nilpy-object-reclamation and the container/owned-object frees). Same lineage that took the uforth microbench 552MB->flat.
Log
- 2026-07-31 — resolved, commit a3dda2e3c.