← board

What happened

f064c591b added a per-unit pre-scan that mints TObject's root methods when a unit mentions a dot-preceded Equals / GetHashCode / ToString:

if unitNeedsRootMeth and (not NoDefaultRtl) and (not TargetIsEspClass) then
begin
  ParseUsesUnitAmbient('builtin');
  EnsureTObjectRootMethods;
end;

lib/rtl/json.pas line 307 is function TJSONValue.ToString(pretty: Boolean) — a qualified IMPLEMENTATION HEADER, which is dot-preceded like a call. So the scan fired on every NilPy program that imports json, and two of them broke:

test before after
test_nilpy_json_reparse_heap.npy prints 4 lines SIGSEGV, no output
test_nilpy_json_module.npy 14 lines 13 — song 120 b 3 missing

The trigger is fixed (bfb7b4c59's successor): the scan now walks back past <TypeName> . and refuses function / procedure, so a definition no longer reads as a use. test_tobject_root_methods_inside_a_unit still passes, because its unit has a real L.Equals(R) CALL as well as the headers — which is the right discrimination.

What is NOT fixed, and why this ticket exists

Minting those rows from a unit should not be able to break the program. It did, and worse than loudly: a segfault in one test and a silently missing line in the other. A unit that legitimately calls .ToString on a TObject and is reachable from a NilPy program would presumably still hit it. That path is now unreachable via json.pas, so nothing gates it.

Not measured, and this is the first thing to establish: which half does itParseUsesUnitAmbient('builtin') pulling a unit mid-parse of another unit, or EnsureTObjectRootMethods adding VMT rows to TObject after some class layouts are already fixed. Split the two and run test_nilpy_json_reparse_heap.npy against each; do not write a cause into this ticket until one of them reproduces it alone. (The reparse test's own header says it exists because a double-finalize once released the caller's string — so a VMT row count changing under a class whose finalizer slot is already resolved is a plausible shape, but plausible is exactly what this repo's playbook says not to bank.)

Repro

Revert the function/procedure guard in pasparser_proc.inc's unitNeedsRootMeth scan, rebuild, then:

./compiler/pascal26 test/test_nilpy_json_reparse_heap.npy /tmp/x && /tmp/x

SIGSEGV, exit 139. CPython prints 1 / 1 / {"a": 1} / x.

Related

The PROGRAM-level trigger this was modelled on has the same definition-reads-as-a-use false positive (a program that defines a class with a ToString method drags in the root-method surface). It does not crash there, so it is a size/waste issue rather than a correctness one — but it is the same missing check, and worth fixing in the same edit as this.

MEASURED and NARROWED 2026-08-25 — it is the Destroy row, not the builtin pull

Both halves were split and run separately, with the false trigger deliberately re-enabled so lib/rtl/json.pas fired the scan:

unit pre-scan does test_nilpy_json_module test_nilpy_json_reparse_heap
ParseUsesUnitAmbient('builtin') + EnsureTObjectRootMethods drops a line SIGSEGV
ParseUsesUnitAmbient('builtin') only pass pass
both, with ONLY the Destroy registration disabled pass pass

So pulling builtin mid-unit is harmless, and minting Equals / GetHashCode / ToString is harmless. Minting a Destroy ROW on TObject from a unit pre-scan is what corrupts the program.

That is consistent with WHEN each caller runs — the program-level caller runs at the END of pass 1, with every class already declared, while the unit pre-scan runs before its own unit's classes are parsed — but the mechanism by which an early Destroy row turns into a segfault is not established, and is deliberately not written here as a guess. (The obvious suspect, the inline PXXClassFinalize double-finalize, is explicitly guarded by isNilPy in ir.inc, so the obvious story does not survive a read.)

Fixed by removing the only way to reach it

EnsureTObjectRootMethods is split: EnsureTObjectRootMethodsEx(withDestroy) carries the body, the old name calls it with True, and the unit pre-scan calls it with False. Destroy has nothing to do with that scan's trigger — a dot-preceded Equals/GetHashCode/ToString — and the routine's own comment already said Destroy is registered separately because it comes from a different unit. The program path still mints it at end of pass 1, after every class.

Verified the fix protects the ACTUAL hazardous path, not just the current one: with the false trigger re-enabled so json.pas fires the scan again, both .npy tests pass. test_tobject_root_methods_inside_a_unit (a unit with a real L.Equals(R) call) and test_pascal_free_through_base_destroy (the ticket that put Destroy in this routine) are both unchanged.

Still open, and why this stays worth a ticket

Nothing explains the crash. It is now unreachable rather than understood, so a future caller that mints a root method early can rediscover it — and the failure mode is a segfault in one program and a silently missing output line in another, which is the expensive kind. Re-file or reopen if a third caller appears. Reproduce by making the unit pre-scan call EnsureTObjectRootMethodsEx(True) and reverting the function/procedure guard beside it.

Gate: make compiler/pascal26 converged, tools/gate.sh quick GREEN.

Log