← board

A pointer cast of an already-owned string retains it for the rest of the program

Measured 2026-09-01 by frankZ at db706c2da, binary 59699dc0833f8110.

Six lines

program rc3;
var s: AnsiString; p: Pointer; k: Integer;
function Cnt: Int64; begin Cnt := PWord(Int64(PChar(s)) - 16)^; end;
begin
  s := '';
  for k := 1 to 44 do s := s + Chr(65 + (k mod 26));
  WriteLn('after build              = ', Cnt);   { 2 }
  p := Pointer(s);  WriteLn('after p := Pointer(s)    = ', Cnt);   { 3 }
  p := Pointer(s);  WriteLn('after p := Pointer(s) #2 = ', Cnt);   { 4 }
  p := Pointer(s);  WriteLn('after p := Pointer(s) #3 = ', Cnt);   { 5 }
end.

Each Pointer(s) site in the MAIN BODY adds one and never gives it back. PChar(s) reads are stable, so the generic pointer cast is the spelling that does it.

What it is NOT — three things I checked before writing this down

So the severity is an owned string that cannot be freed while main runs, plus every refcount assertion in the corpus reading one too many.

What it broke

test-threads#src:test/test_threadsafe_refcount_lockfree.pas, deterministic 5/5, fail=3:

FAIL heap handle starts at rc=1
FAIL heap handle back to rc=1 after the parallel hammer
FAIL heap handle back to rc=1 after the array dropped it

All three read the count through the test's own RC(v) = PWord(Int64(Pointer(v)) - 16)^ helper — i.e. through the very cast that does the retaining, which is why exactly the rc=1 rows fail and the saturation rows pass.

Where it came from

Track T's bisect: last good 645259ff18c0, bad 1e37a55f6748, and the ONE buildable commit in that range is b788c5865 "one helper for the string-to-pointer ownership seam, and the three that were still leaking" — the eighth instance of that shape, which introduced IRParkManagedStr and made all eight sites call it.

That commit's own header says the mechanism: IR_STORE_SYM "MOVES a fresh call result and RETAINS anything else, so an already-owned value gains a balanced retain/release rather than a second free." The retain is by design; where its release is parked is the defect. For a fresh temp the pair is invisible. For an already-owned variable the retain lands now and the release lands at scope exit, which is correct in a routine and wrong in the main body.

Two directions worth weighing, and I do not have a recommendation between them: skip the park entirely when the operand is already an owned symbol (nothing needs parking — the symbol's own scope owns it), or keep it and release at the end of the STATEMENT rather than the scope.

Not claimed

The string-to-pointer seam is under active work by whoever landed b788c5865 — eight instances of one shape, deliberately being generalised. Filed rather than fixed so the ninth instance is not me, working the same question from the other end. It blocks [[umbrella-one-full-tier-run-with-no-red-tier]].

Resolved

Fixed by the session that landed b788c5865 (frankB). frankZ's reduction was exact and the six-line repro reproduced first try: 2/3/4/5 before, 1/1/1/1 after, matching FPC 3.2.2 row for row.

Taken direction one — skip the park when the operand is already owned — but decided with the compiler's own predicate rather than a shape test. IRNodeOwnsManagedStr (and IRNodeOwnsFreshCallResult for the dyn twin) already answers "does this operand own its +1", and both were already forwarded for the cross backends; the forwards moved above ir.inc rather than being duplicated, since a second forward is one the FPC seed rejects.

Direction two (release at end of STATEMENT) was rejected for the string park: end-of-statement is shorter than the temporary lifetime FPC guarantees, so it would have broken the seams the park exists for. It turned out to be the right answer's mirror image one bug over, though — the interface function-result temp was on the end-of-statement queue and needed the opposite move, to scope exit. Both are in the same commit.

Regression test test/test_pointer_cast_owned_string_refcount.pas, wired into test-core, asserting both directions: the count stays at 1, AND the three seam-leak rows still bound at 50. One without the other passes when the park is deleted outright.

Fixed in commit d5e0a1e48.

Log