← board

An unknown field on a BUILTIN record was accepted, and corrupted memory

What happened

TParam (defs.inc:953) has no ProcSig field. This compiled clean anyway:

Procs[ProcCount].Params[i].ProcSig := -1;      { symtab.inc, RegisterProc }

pxx built a working compiler from it. FPC refused the same source:

symtab.inc(5389,32) Error: identifier idents no member "ProcSig"
parser.inc(26814,30) Error: identifier idents no member "ProcSig"
ir.inc(2037,52)      Error: identifier idents no member "ProcSig"

The write landed at the not-found default — tyInteger, offset 0 — and clobbered a neighbouring field. Two consequences in one build: the READ never saw what the WRITE stored (so the feature silently did nothing), and test/quick_canary_nilpy.npy segfaulted — an unrelated NilPy test in the quick tier.

Root cause

TSymbol, TParam and TProc are builtin-mirrored records — the compiler carries hand-written layouts for its own core structs so it can self-host (symtab.inc:1114):

REC_TSYMBOL = 6;   REC_TPARAM = 7;   REC_TPROC = 8;

All below REC_UCLASS_BASE = 16. Two independent things then let the typo through:

  1. RequireRecMember is gated on recId >= REC_UCLASS_BASE. Its own comment states the assumption — "Builtin records keep the lax path — their field names are compiler-authored" — so it no-ops for recIds 6/7/8.
  2. The statement-lvalue path never calls it anyway. There are only three RequireRecMember call sites and all three are expression paths; a breakpoint on the guard was never reached for Syms[0].Bogus := -1.

RecFieldType then fell out of its builtin-field loop and returned the Result := tyInteger it was initialised with, at offset 0.

This is why no minimal case reproduces it. An ordinary user record gets recId >= 16 and is correctly rejected — four separate minimal shapes were tried and every one errors properly. The bug is reachable only where a record is builtin-mirrored, i.e. in the compiler's own source.

Fix (landed)

Reject at the point the miss is decided, in RecFieldType's builtin branch: a builtin record's field names are compiler-authored, so a miss is always a typo and never a legitimate probe. Falling through to offset 0 is never right.

That placement is deliberate — it is path-independent, so it also covers the unguarded statement-lvalue path that RequireRecMember never sees.

Verified

Residual — filed separately

RequireRecMember still only covers recId >= REC_UCLASS_BASE, and the statement-lvalue path still does not call it (proven by breakpoint). That no longer produces a silent wrong store — the RecFieldType check is path-independent — but the three-call-sites-against-~20-AN_FIELD-builders asymmetry is a latent inconsistency. Filed as [[bug-pascal-member-check-missing-on-the-lvalue-field-path]] (prio 45), with the NilPy caveat that a member miss on a dynamic-attribute path can be legitimate.

Written up

The general lesson — the compiler's own core structs are a SECOND type-identity space, with different rules from the language's view of the same records — is in devdocs/dev/type-identity-as-substrate.md, as a new closing section. It belongs there because it is the same invariant break the rest of that note catalogues, one layer down: ValidateBuiltinRecordLayout checks the two declarations agree on LAYOUT, and nothing checked they agree on which members EXIST.

Log