← board

A class var before an instance field corrupts the instance layout — WRONG

The retraction, and it is the only part worth reading

const and class var in a class body open a section. A plain field declaration after one does not end it — it is absorbed into it. var is the keyword that closes the section and goes back to per-instance storage:

TBox = class
  class var LIMIT: Integer;
  r: TRec;        { <- a CLASS var. One `r` for the whole class. }
end;

TBox = class
  class var LIMIT: Integer;
  var
    r: TRec;      { <- an instance field, which is what was meant }
end;

Nothing is displaced and no offset is wrong. PXXDBG=a.reclayout on the failing source prints no TBox line at all — with-fields=13 fields=72 against with-fields=14 fields=73 for the working order, one aggregate and one field fewer. The field never becomes an instance field, so every object reads one shared global; that is the whole of the "two instances overlap" symptom.

The discriminator that separates absorption from a wrong offset — and it is one line, so it was cheap to have had first: name the field through the CLASS, with no instance ever constructed.

TBox.r.W := 7;  TBox.r.H := 9;  WriteLn(TBox.r.W, 'x', TBox.r.H);   { prints 7x9 }

A field at a wrong instance offset cannot answer that. A field in the ClassVar registry answers it exactly.

The oracle

fpc 3.2.2 on this box, the repro from this ticket, unmodified except for the mode line:

class var then r: TRec; class var then var r: TRec;
pxx (HEAD) a = 0x0 a = 64x64
pxx (pin) a = 64x64
fpc 3.2.2 a = 0x0 a = 64x64

No warning from either compiler. The const-section half is refused by fpc with Syntax error, "=" expected but ";" found — pxx says expected '=' before ';', the same refusal. Both halves of this ticket are parity with the reference compiler, which is why this is rejected rather than fixed.

Why it survived reduction

The four-row table below is correct data. It was read as the wrong mechanism, and the reading is a reasonable one: a shared field and a displaced field produce the same alarming symptom — a second construction emptying the first object — and the dynamic array turns both into a segfault. What separates them is not visible from the program's output at all. The reduction was sound work; only the sentence explaining it was wrong.

What changed in the tree

Not proposed: a warning

class var A: Integer; B: Integer; is the legitimate spelling of a two-entry class var section. Nothing distinguishes it from a mistyped instance field, so a diagnostic would fire on correct code. That is why the language has var rather than a warning, and it is the argument against adding one here.


The original report follows, unedited

The repro, whole

unit u;
{$MODE PXX}
interface
type
  TElem = record R, G, B, A: Byte; end;
  TRec  = record W, H: Integer; Data: array of TElem; end;
  TBox = class
  public
    class var LIMIT: Integer;     { <-- move this below `r` and the bug goes }
    r: TRec;
    constructor Create(w, h: Integer);
    function shrink: TBox;
  end;
implementation
constructor TBox.Create(w, h: Integer);
begin r.W := w; r.H := h; SetLength(r.Data, w * h); end;
function TBox.shrink: TBox;
begin Result := TBox.Create(0, 0); end;
end.
program r;
{$MODE PXX}
uses u;
var a, b: TBox;
begin
  a := TBox.Create(64, 64);
  b := a.shrink;
  WriteLn('a = ', a.r.W, 'x', a.r.H, '  (want 64x64)');
end.
a = 0x0  (want 64x64)

The four rows that locate it

Same unit, same program, only the class body changed:

class body result
class var LIMIT: Integer; then r: TRec; a = 0x0 — wrong
r: TRec; then class var LIMIT: Integer; a = 64x64 — correct
two class vars, both before r a = 0x0 — wrong
no class var at all a = 64x64 — correct

So it is the order, not the presence. A class var is storage that belongs to the CLASS and must not occupy an offset in the instance; it is evidently being allocated one, and the instance fields after it are displaced by exactly that much.

Three reductions that do NOT reproduce it

Worth having, because they are the first things to try and each cost a cycle:

The dynamic array is not the trigger. It is what makes the damage VISIBLE and fatal: SetLength(..., 0) on the aliased field frees the first object's buffer, so the next read is of freed memory rather than of a stale but mapped value.

How it presented, before it was reduced

lib/rtl/pil.pas (from PIL import Image). Image.resize constructs its result and then resamples into it:

DBG resize self=...677952 bmp=64x64
DBG ctor   self=...678168 want 0x0   bmp now 64x64     <- the NEW object already sees the OLD one's record
DBG ctor   done self=...678168 bmp=0x0
DBG resize after: self=...677952 bmp=0x0                <- the OLD object has been emptied

The constructor is correct, the call site is correct, and the receiver is destroyed anyway. Two earlier hypotheses — that the class-qualified constructor call was degrading to a Self call, and that a record-with-dynamic-array class field was aliasing in general — both fit the evidence and are both wrong; the four-row table above is what separated them.

Note for whoever fixes it

There is a SECOND, unrelated and much more benign ordering rule in the same area, found while reducing this one: a const section inside a class does not end at a plain field declaration, so const X = 5; followed by f: TSomething; is refused with "expected '=' before ';'". That one is loud and is only mentioned so it is not mistaken for this.

lib/rtl/pil.pas currently orders its declarations around this bug and says so at the declaration; it is registered in devdocs/dev/track-b-workarounds.md.