Commit graph

998 commits

Author SHA1 Message Date
Graeme Geldenhuys 5754ca254b build(bif-coverage): add stdlib unit path so it builds by default
The tool is now activeByDefault, so it is reached by a plain
'pasbuild compile'. With manualUnitPaths=true it only searches the listed
paths, which lacked the stdlib directory where SysUtils and the other
units it uses live, so the default build failed with 'Unit SysUtils not
found'. Add ../../stdlib/src/main/pascal, mirroring the compiler module's
own stdlib unit path.
2026-06-29 12:29:55 +01:00
Graeme Geldenhuys a934d73566 fix(bif): serialise the overload directive across the unit-interface boundary
Imported methods always had IsOverload=False: the .bif method layouts
round-tripped IsVirtual/IsOverride/IsStatic but not IsOverload, and the
importers never set it. ResolveMethodOverload's hiding walk stops at the
first non-overload candidate, so an overload set split across an imported
class and an imported ancestor would be truncated to the more-derived
level (latent today because the known overload sets live on a single
class).

Add IsOverload to both method-encoding paths and bump BLAISE-IFACE 5 -> 6:
  * TRoutineSig (class methods): field added in uUnitInterface, set in
    BuildRoutineSig, encoded/decoded in EncodeMethodSig/ReadMethodSig,
    propagated in SynthesiseMethodDecl.
  * TMethodDecl (interface and generic-template methods, the
    EncodeMethodDecl/ReadMethodDecl path): the same flag was dropped there
    too, so an overloaded interface method lost its directive across the
    .bif.

Round-trip tests for both paths: TestRoundTrip_Class_WithOverloadedMethods
and TestRoundTrip_Interface_WithOverloadedMethods. bif-coverage status
gains TRoutineSig.IsOverload.
2026-06-29 12:29:55 +01:00
Graeme Geldenhuys fdf99b26c0 test(opdf): guard recProperty layout integrity for method-backed properties
The recProperty corruption (garbled Name/ReadMethod/WriteMethod string
fields) was fixed in c6d22aac, but the only property test asserted just
the '# recProperty' comment — it would not have caught the RecSize/layout
mismatch that caused the reader to overshoot into the next record.

Add TestOPDF_Property_MethodBacked_LayoutIntact: for a method-backed
property it asserts the exact RecSize (32-byte fixed payload + the three
string lengths), the getter/setter symbol strings, and the three length
words. This pins the writer's declared size to the actual emitted bytes,
which is the invariant the reader (SizeOf(TDefProperty)=32 then three
length-prefixed strings) depends on.
2026-06-29 11:00:24 +01:00
Graeme Geldenhuys 25cd8e3ee7 test(sepcompile): cover static property in cross-unit .bif round-trip
The cross-unit static-members tests (QBE + native) already drive a static
var, static method and static const through the warm --unit-cache .bif
boundary. Add a static property (a getter-backed 'static property Counter
read Next') to the shared test unit so TPropertyDecl.IsStatic surviving the
.bif round-trip is guarded too: a non-static import would dispatch the
getter with a bogus Self. Expected stdout updated to include the extra
Counter read.
2026-06-29 10:08:44 +01:00
Graeme Geldenhuys 8d1cdc8f61 fix(codegen): chained l-value base through a qualified static var
'TFoo.X.Field := V' and 'TFoo.X.Method()' failed semantic analysis with
"Field access requires a record or class base, got 'class of TFoo'".
The read form (TFoo.X.Field as an expression) already worked.

When a qualified static var is the base of a further chain on an l-value,
the parser shapes it as a Base-form field access whose base is the bare
class-name ident resolving to the 'class of TFoo' metaclass, rather than
the simple RecordName form. AnalyseFieldAccess's chained-base path
rejected a metaclass base outright.

Semantic: in the chained-base path, when the base type is a metaclass,
resolve the field as a static var (IsClassVarRead) or static property
(IsStaticPropGet) of the metaclass's base class, mirroring the RecordName
static-member read path, so the result type feeds the outer chain.

QBE codegen: EmitInstancePtr loads the instance pointer from the static
var's global slot for an IsClassVarRead base, and the field-access reader
checks IsClassVarRead before the chained-base branch so a Base-form static
var is loaded directly rather than treated as a chained field of the
metaclass. The native backend already produced correct code.

Tests cover the read, a scalar field write, and a method call through the
chained static-var base on both backends.
2026-06-29 01:13:00 +01:00
Graeme Geldenhuys e950814a34 fix(codegen): interface-typed static var store via static methods
A static var of interface type round-tripped through static methods
(THolder.SetIt(X: IThing) / THolder.GetIt: IThing) was miscompiled on
both backends. The 2-slot fat-pointer global slot itself was correct;
the bugs were in the static-CALL path.

QBE: a static method whose first parameter is interface-typed emitted
'call $T_M(, l obj, l itab)'. The interface argument fragment carries a
leading ', ' (it is normally appended after Self or a preceding arg), and
the static-call argument loop did not strip it, so the first argument
began with a comma and QBE rejected it as an invalid class specifier.
Strip the leading ', ' in that branch, matching the existing
EmitMethodCall var-arg/interface precedent.

Native: a static method returning an interface routed through
EmitClassIntfSretMethodCall (ResolvedClassType is the owning class, a
class type), which loaded the bare type name as a global Self and emitted
an undefined reference to the class symbol at external-cc link time (the
internal/driver linker masked it). Add an IsStaticCall branch that emits
the plain free-function interface-sret ABI with no receiver, mirroring
the non-Self arm of EmitIntfSretCall.

Tests: TestIR_StaticCall_InterfaceArg_NoLeadingComma asserts the QBE call
has no leading comma; TestRun_StaticVar_InterfaceStore compiles and runs
the SetIt/GetIt round-trip on both backends.
2026-06-29 01:01:25 +01:00
Graeme Geldenhuys ceb566ed0f feat(lang): lower qualified static-var write TFoo.X := V
A qualified write to a class-level static var was parsed and visibility-checked
but not lowered — semantic reported it as "not yet supported".  It now lowers
to a plain store of the shared global slot, identical to the bare form a static
method writes (StaticVar := V).

Semantic marks the TFieldAssignment with IsClassVarWrite + ClassVarEmitName +
ClassVarLhsType after enforcing visibility.  Both backends delegate to their
existing global-store path (EmitAssignment) via a borrowed synthetic
TAssignment — the Expr is shared and nilled before the temp is freed — so the
scalar, class-ARC, and pointer store logic is reused verbatim rather than
duplicated.

Verified on both backends: scalar (int/enum/bool/float), class with ARC
(assign + nil-release).  Adds e2e TestRun_StaticVar_QualifiedWrite_Scalar and
_ClassARC (each compiled+run under native and QBE).

Two adjacent limitations remain, both pre-existing and independent of this
change (documented in bugs.txt): interface-typed static vars mishandle the
2-slot fat-pointer store (the bare form is equally affected), and a chained
l-value base through a qualified static var (TFoo.X.field := V) is unresolved
in the assignment-receiver path while the read form works.
2026-06-29 00:31:15 +01:00
Graeme Geldenhuys a903c83c89 feat(lang): enforce visibility on bare static-var access too
The initial visibility pass checked the qualified form (TFoo.StaticVar) but not
the bare unqualified form, which resolves to the same shared global.  A
strict-private static var was therefore still reachable by name from another
type, the program body, or a unit's initialization section — contrary to the
design (a strict-private member is reachable only from its declaring type's own
methods).

Enforce member visibility at the bare static-var read (AnalyseIdentExpr) and
write (AnalyseAssignment) sites, and at the qualified read (AnalyseFieldAccess),
matching the qualified-write site added earlier.

A static method leaves FCurrentClass nil (so implicit-Self refs fail cleanly),
which would wrongly reject a strict-private static var accessed from its own
type's static method — the singleton's `static function TFoo.Instance` reading
bare FInstance.  Track FCurrentMethodOwner (the declaring type of the current
method body, set for static and instance methods alike) and use it as the
"from" class for static-var visibility via the new AssertStaticVarVisible, so
own-type static methods keep access while non-method contexts (program body,
unit init/final) do not.

Adds cp.test.visibility cases for bare-from-other-type, from-program-body, and
non-strict-private-from-program-body (the unit-init-scope analogue), plus a
public static var qualified cross-type read.
2026-06-29 00:10:48 +01:00
Graeme Geldenhuys a4190d73cf feat(lang): enforce member visibility — private/protected/strict
Visibility modifiers on class and record members are now enforced, not merely
parsed.  A `private` member is reachable only within the declaring unit; a
`protected` member additionally within descendant types; `public`/`published`
everywhere the type is.  Adds `strict private` and `strict protected`, which
narrow visibility to the declaring type itself (and, for strict protected, its
descendants) rather than the whole unit.  `strict` composes with `static`.

Parser: track the current visibility section in class/record bodies and the
contextual `strict` keyword (only before private/protected); carry the
visibility onto each field, method, and property declaration.  `strict public`,
`strict published`, and a bare `strict` are rejected.

Semantic: every qualified and unqualified member-access site checks visibility
via MemberVisibleTo / AssertMemberVisibleV, using the member's declaring unit
and declaring type.  Static (class-level) vars now carry Visibility and
OwnerTypeName on their TSymbol so a qualified static-var access enforces the
same rules; a strict/private static var written from another type is rejected
with a "not accessible" diagnostic.  Qualified static-var writes from a
permitted context are reported as not-yet-lowered rather than mis-resolved
(permitted writes use the unqualified form inside a static method).

Cross-unit: member visibility and declaring-type/unit origin are carried across
separately-compiled units in the .bif interface (BLAISE-IFACE version 5) so the
checks hold for imported types.

Updates docs/grammar.ebnf with the visibility-section grammar and adds
cp.test.visibility (parse + semantic enforcement) plus thread-test fixes that
switched two TThread subclasses from private FTerminated/FFinished fields to
the public Terminated/Finished properties.
2026-06-28 23:46:52 +01:00
Graeme Geldenhuys 7979f23eb1 fix(codegen): re-assert owning-unit context before impl-section class emission
A class declared in a unit's implementation section (IsImplPrivate) referenced
as a metaclass value in that same unit's initialization block — e.g.
RegisterTest(TFoo) — could be emitted under a bare, unqualified, undefined
typeinfo symbol, linking to garbage and crashing at runtime.

Per-unit codegen sets the symbol-table viewing context (DefineOwningUnit) to the
unit being emitted, so TSymbolTable.Lookup's cross-unit-leak guard does not
suppress the unit's own impl-section types.  But earlier emit passes in EmitUnit
(method-body type resolution that walks the uses chain) leave DefineOwningUnit
pointing at a dependency unit.  By the time the class-data section and the
initialization block are emitted, a metaclass reference to an impl-section class
resolved to nil and ClassSymName/ClassUnitPrefix fell back to the bare class
name.  Only per-unit separate compilation exposed this; whole-program emit-ir
mode kept the context stable, so all fixpoints and the IR harness passed while
the linked binary held a garbage typeinfo pointer.

Re-assert DefineOwningUnit := AUnit.Name immediately before the class-data
section emission and before the initialization-block emission, in both the
native x86-64 and QBE backends.

Add Test{Native,QBE}ImplSectionMetaclassInInit_Runs to cp.test.e2e.sepcompile,
exercising an impl-section class assigned to a class-of global in the unit's own
init on both backends.
2026-06-28 21:25:42 +01:00
Graeme Geldenhuys 02de7b694a feat(lang): carry static members across separately-compiled units
Follow-up to the within-unit static-members feature (0977dc16).  Static
class/record members declared in one unit can now be used from another through
the compiled `.bif` interface (the `--unit-cache` / separate-compilation path),
not only when every unit is recompiled from source.

The `.bif` wire format already carried most static facts; the values were being
dropped by consumers around the serialiser:

* Export clones — CloneFieldDecl / ClonePropertyDecl / CloneMethodDecl now copy
  IsClassVar / ClassVarEmitName, property IsStatic, and method IsStatic.  The
  same clone gap silently broke the pre-existing IsWeak / IsDefault round-trip
  ([Weak] fields and `default` properties exported as plain) — fixed here too.
* Method static-ness — TRoutineSig gained an IsStatic field (a final non-virtual
  instance method and a static method both carry VTableSlot = -1, so a dedicated
  flag is required).  Populated on export, encoded/decoded symmetrically
  (BLAISE-IFACE version 3 -> 4), and applied to the synthesised TMethodDecl on
  import so `TypeName.StaticMethod()` resolution succeeds.
* Import — ImportClassEntry / ImportRecordEntry register an imported static var
  as the shared global (bare + qualified skVariable symbols carrying the
  *decoded* GlobalEmitName, never recomputed — the importing unit's prefix
  differs), carry property IsStatic onto TPropertyInfo, and import the type's
  static ConstDecls so `TFoo.MaxItems` resolves.
* Parser — out-of-line `static function T.M` bodies in a unit's implementation
  section are now parsed (mirrors the program-level standalone path).

Tests: TestStaticMembers_CrossUnit_QBE / _Native in cp.test.e2e.sepcompile
(cold + warm `--unit-cache` round-trip, both backends), and
TestRoundTrip_StaticMembers_Preserved /
TestRoundTrip_WeakField_And_DefaultProperty_Preserved in cp.test.unitinterface.
Full suite green on QBE- and native-built runners (3912 tests); all fixpoints
pass (incl. warmcache, which exercises the .bif round-trip); bif-coverage clean.
2026-06-28 16:35:36 +01:00
Graeme Geldenhuys 0977dc16cb feat(lang): static class/record members (within-unit)
Introduce `static` (class-level) members to the Blaise language using the
`static` keyword — never an overloaded `class` keyword. A `static` member is
type-associated, not instance-associated: static methods take no implicit
Self, and static vars/consts are a single shared storage slot.

Surface, on classes and records:

* `static var` / `static const` — section form (`private static var`) or as a
  bare `static` continuing the current visibility. Static vars lower to one
  shared global slot (mangled `<Unit><Type>_<Name>`), zero-initialised, NOT an
  instance field. Class- and interface-typed static vars are supported (the
  canonical singleton storage) with store-time ARC and a program-exit release;
  string and dynamic-array static vars remain deferred.
* `static function` / `static procedure` — per-member prefix or section form;
  no implicit Self. Out-of-line bodies are `static function T.M`.
* `static property` — sugar over a static getter (no Self at the call site).
* record `static function` — the factory / namespaced-function form
  (`TPoint.Make(x, y): TPoint`), required to be marked `static` explicitly.

There is no `static constructor` / `static destructor` (rejected at parse):
the zero-initialisation guarantee covers nil singletons, and eager setup
belongs in a unit's `initialization`/`finalization` (the Swift/Rust/Go model,
not Java/C#/Delphi). `class` is never a member qualifier.

Implementation spans the full pipeline:

* parser — `static` is a soft keyword; section qualifier (followed by
  var/const) and per-member prefix forms; `static constructor/destructor`
  rejected.
* semantic — static vars register a shared global (bare + qualified) under a
  mangled emit label; static methods skip the Self binding; qualified
  `Type.StaticVar` / `Type.StaticProp` / `Type.StaticMethod()` resolution.
* QBE + native x86-64 codegen — no-Self method signatures and call sites
  (including the record-return sret and >6-arg paths), shared global data
  slots, qualified static var/property reads, and class/interface static-var
  release at program exit.
* `.bif` interface format — IsClassVar/ClassVarEmitName, property IsStatic, and
  record/class const decls are encoded (BLAISE-IFACE version 2 -> 3).
* OPDF debug info — static vars are emitted as `recGlobalVar`s under their
  mangled label so a debugger can print `TFoo.FInstance`.

Static members currently work within a single program/unit; carrying them
across separately-compiled units (export clone, import, TRoutineSig.IsStatic)
is a tracked follow-up — the .bif wire format is already in place for it.

Tests: cp.test.staticmembers (parser + semantic + IR) and
cp.test.e2e.staticmembers (compile+run on both backends). Full suite green on
QBE- and native-built runners (3908 tests); all fixpoints pass; bif-coverage
clean. docs/grammar.ebnf and docs/language-rationale.adoc updated.
2026-06-28 16:11:40 +01:00
Graeme Geldenhuys 50b8d75e6d fix(parser): enforce mandatory parentheses on statement-position calls (#148)
The "mandatory parentheses on zero-argument calls" rule (language-
rationale.adoc) was enforced in expression position but NOT in statement
position: a bare `Foo;`, `Obj.Method;`, or `Obj.Free;` used as a statement
compiled silently, building a paren-less TProcCall / TMethodCallStmt. Issue
#148 reported `tester.print;` (a unit's global object method call) being
accepted without its mandatory ().

The statement parser now raises the same "requires () for a call" diagnostic
the inherited-call and expression-position paths already use, at the two
fall-through sites:

  * bare unqualified call `Foo` with no '(' (the final ProcCall else-branch);
  * bare `Obj.Method` with no '(' and no further '.' chain.

Field reads, field assignments, indexed writes, and '.'-chains are unaffected
(only a terminating bare reference is rejected). Expression-position calls
were already enforced.

Enforcing the rule required the compiler, RTL, and stdlib to comply first
(self-hosting): swept bare calls in uSemantic (Flush/RepairGenericInstances),
runtime.arc/runtime.exc (_libc_abort), blaise.codegen.native.backend
(FAsm.AppendLine), and a json.writer doc example. The test suite embedded
many bare calls in inline program strings — all updated to carry (); adding
() never changes behaviour since these were always calls.

cp.test.parser.pas gains two parse-error tests (proc and method bare calls);
the old TestProcCall_NoParens, which asserted the bug, is inverted. grammar.ebnf
SubscriptMethodCall made parens mandatory and an example corrected; rationale
notes statement-position enforcement.
2026-06-28 10:15:39 +01:00
Graeme Geldenhuys a205c22136 fix(codegen): empty loop body no longer crashes the compiler (#150)
`for x := 0 to N do;` and `while C do;` have an empty statement as their
body, which the parser represents as a nil statement (its convention for
"no statement here" — every statement-list builder already filters nil).
A loop's single Body field is not filtered, so the nil reached EmitStmt:

  * Native backend: EmitStmt fell through every `is` check to the
    unsupported-statement fallback, which dereferenced AStmt.ClassName on
    nil — segfaulting the compiler (issue #150 was reported against the
    default native backend).
  * QBE backend: EmitStmt raised "EmitStmt called with nil statement",
    rejecting a legal empty body.

Both backends now treat a nil statement as a no-op, matching the parser's
nil-as-empty convention and the way AnalyseStmt already tolerates it. An
empty loop body compiles to a loop that does nothing.

E2E regression in cp.test.e2e.controlflow.pas asserts both `for ... do;`
and `while ... do;` compile + run as a no-op on every backend.
2026-06-28 09:34:25 +01:00
Graeme Geldenhuys f517f5148a feat(debug-opdf): emit recRuntimeHelper records for the RTL release routines
The OPDF emitter now writes one recRuntimeHelper (= 25) record per binary
for _StringRelease (kind 0) and _DynArrayRelease (kind 1), each carrying
the linker-resolved entry-point address.

These let the debugger inject a call to the matching RTL release routine
to free the +1 transient an injected property getter returns — without
them, `print x.Bar` on a method-getter property leaks one string/dynarray
per call in the debuggee's exit leak report.  The records are emitted only
by the program object (the helpers are global RTL symbols, not per-unit),
alongside the existing recUnitDirectory.

IR tests assert both records, their address quads, and the kind ordinals
(which must match opdf_types.TRuntimeHelperKind).  The reader side lives
in the opdebugger repo.
2026-06-28 01:26:13 +01:00
Graeme Geldenhuys c6d22aac18 fix(opdf): emit complete recProperty layout (method-name strings)
The OPDF recProperty record written by EmitProperties did not match the reader's
TDefProperty layout: it emitted only the property Name and omitted the
ReadMethodNameLen / WriteMethodNameLen length words and the two method-name
strings the reader expects.  A consumer (pdr) therefore read a misaligned
record — a garbage property name and an empty getter symbol — so a method-backed
property such as `property Bar: String read GetBar` could not be resolved
(`print x.Bar` reported "field or property not found").

EmitProperties now writes the full format the reader specifies: the three length
words (ReadMethodNameLen, WriteMethodNameLen, NameLen) followed by the
ReadMethodName, WriteMethodName and Name strings.  For a method-backed accessor
the method-name string is the mangled getter/setter symbol — the same symbol
written as ReadAddr/WriteAddr and emitted as the recFunctionScope name, so the
debugger can resolve it via FindFunctionByName and inject the getter call.

Verified end-to-end against the OPDF reference debugger (pdr): `print x.Bar`
now invokes the getter and prints the value; the pdr integration suite is green.
2026-06-28 00:48:03 +01:00
Graeme Geldenhuys 43a0f9e4eb fix(arc): release string call/getter transient passed to Write/WriteLn
A string-returning function, method or property getter used DIRECTLY as a
Write/WriteLn argument — WriteLn(GetBar) — returns a fresh +1 string that
_SysWriteStr only borrows.  EmitWrite emitted the write call but never released
that transient, leaking one string per call on BOTH backends.  (A normal
procedure's const-string parameter already released it; only the Write/WriteLn
built-in path was missing the release.  Assigning the result to a variable
first never leaked.)

EmitWrite now releases the string argument after the write when the argument
expression OWNS its reference (ExprOwnsRef / NativeExprOwnsRef) — a call or
concat result.  Plain variables, literals and PChars are borrowed and are not
released, so no double-free.  Native stashes the pointer across the
_SysWriteStr call and releases it after.

E2E regression (both backends, --debug leak tracker):
TE2ELeakCheckTests.TestDebug_WriteLnCallArg_NoLeak.
2026-06-28 00:47:53 +01:00
Graeme Geldenhuys a4d178b52c fix(codegen): itab dispatch of a record-returning method (both backends)
An interface (itab-dispatched) method returning a RECORD was broken on both
backends, both when the result was assigned and when discarded in statement
position, for memory-class (sret) and register-class records alike.  The itab
dispatch had no record-return ABI: it emitted a scalar call, so for a
memory-class return the first argument register doubled as the sret pointer
(the callee received a garbage argument and wrote the record over caller
memory — "_StringRelease corrupted header"), and a register-class return was
dereferenced as a pointer.  Assignment was equally broken because
IsRecordCall / IsNativeRecordCall keyed off ResolvedMethod, which is nil for
itab dispatch.

QBE:
* IsRecordCall recognises an itab record call (ResolvedClassType = interface +
  ResolvedType in [record, static-array]) for both the args form
  (TMethodCallExpr) and the zero-arg form (TFieldAccessExpr.IsInterfaceCall).
* EmitIntfSretDispatch builds the visible args (Self, method args) and delegates
  to EmitRecordReturnCallSite, which applies the correct ABI from
  ClassifyRecordReturn (sret pointer vs register capture) — instead of always
  passing an sret buffer.
* The discarded-statement path (EmitMethodCall) gained a record/static-array
  branch: a throwaway zeroed buffer, the classified call, then
  EmitRecordReleaseFields on the buffer.

Native:
* IsNativeRecordCall recognises the itab record case.
* New EmitIntfRecordSretDispatch performs itab dispatch with the full
  record-return ABI: an sret buffer in %rdi for a memory-class record, or
  EmitRecordRegReturnCapture (a new helper covering rcInt1/rcInt2/rcSSE1/
  rcSSE2/rcIntSSE/rcSSEInt) storing the register result into the destination.
  Wired into EmitRecordCallSretAt, both record-assignment branches (implicit-Self
  field and plain local/var/global), and the discarded-statement path.

E2E regressions (both backends, via AssertRunsOnAll):
TE2EInterfaceTests.TestRun_InterfaceMethod_ReturnsSretRecord and
_ReturnsRegisterRecord — each exercises an assigned and a discarded call.
2026-06-27 21:52:23 +01:00
Graeme Geldenhuys 4bd52cd96a fix(arc): release static-array-of-interface elements at scope exit
A local `array[0..N] of IFoo` stored interface elements but never released
them when the array went out of scope, leaking one obj reference per element
on BOTH backends (the scope-exit ARC sweep had no static-array case).

Both backends now release each interface element of a static-array-of-
interface local at scope teardown, via shared helpers
EmitStaticArrayReleaseElems / EmitManagedReleaseAt (per-backend).  The QBE
exception-path cleanup zeroes each released slot so an outer handler's cleanup
stays a no-op.

Scope is restricted to INTERFACE elements on purpose.  Static-array-of-
class/string/record locals are NOT released here: the element store retains
unconditionally while owning code manages some such arrays manually (the ELF
writer frees each `RelaBuf: array[0..5] of TByteBuf` element with `.Free`), so
a blanket scope-exit release double-frees and corrupts the allocator — it
crashed the native self-hosted compiler (caught by fixpoint-warmcache.sh).
EmitRecordReleaseFields likewise still skips static-array fields, to stay
symmetric with the retain/copy paths.  Both gaps are documented for a later,
symmetric fix.

E2E regression (both backends):
TE2ELeakCheckTests.TestDebug_StaticArrayOfInterface_NoLeak.
2026-06-27 21:23:37 +01:00
Graeme Geldenhuys 1e3f3ccc5d fix(capture): nested-proc capture of outer var-param records and arrays
A nested procedure that captured a `var` parameter (record or static
array) of its enclosing proc emitted the parameter as a global symbol
reference instead of reaching it through the closure pointer, so the
program either failed to link or read/wrote the wrong storage.

Three layers:

* Semantic (uSemantic): CollectCaptures now takes the enclosing
  TMethodDecl and seeds the outer-name set from its PARAMETERS as well
  as its locals, so a captured var-param is detected.  Capture detection
  also descends through TFieldAssignment, TStaticSubscriptAssign,
  TFieldAccessExpr and TStringSubscriptExpr, so `R.F := ...`, `A[i] := ...`
  and their reads contribute the receiver/array name.  The repeated
  "add if outer and not already captured" idiom is hoisted to
  MaybeCaptureName.

* QBE backend: VarRef routes a captured name through `%_cap_<Name>`.
  The aggregate-ident read distinguishes a captured plain local (the
  `_cap_` slot IS the aggregate address) from a captured var/out param
  (the slot holds the caller's pointer, needing one extra load).

* Native backend: base resolution is unified through EmitVarBaseToReg,
  which checks IsCaptured first.  The static-array element paths and the
  aggregate-ident path now treat a captured var-param array like the
  record field paths: AWantAddress=False for a var-param capture (one
  deref to the data pointer), True for a plain-local capture.

E2E regression tests (run on both backends): capture of a var record
param, a plain local record, and a var static-array param.
2026-06-27 20:56:11 +01:00
Graeme Geldenhuys b5726091be feat(units): support classes declared in a unit implementation section
A class (or record-with-methods) declared in a unit's `implementation`
section is now fully supported: its methods are accepted, its bodies are
type-checked, and its type metadata (typeinfo, vtable, _FieldCleanup) is
emitted on both backends so the unit links and runs.

Three layered problems, fixed together:

1. Semantic ordering + body analysis (uSemantic.AnalyseUnitForExport):
   impl-section TYPE decls are registered before LinkClassMethodImpls (so an
   impl-section class has its methods in FMethodGroups), and ImplBlock method
   bodies are now analysed alongside IntfBlock ones (otherwise field/param
   references carried no ResolvedType and codegen aborted).

2. Codegen emission: native EmitUnit and QBE AppendUnit walked only
   IntfBlock.TypeDecls.  They now emit ImplBlock classes too — method bodies,
   class section (typeinfo/vtable/_FieldCleanup) and interface defs — under the
   owning unit's mangling prefix.  Each backend establishes the emitted unit as
   the symbol-table viewing context so the impl-private class resolves during
   emission and the reference/definition symbol names agree.

3. Cross-unit leak (the blocker): impl-section symbols were registered in the
   shared global scope and leaked into unrelated units via TSymbolTable.Lookup's
   flat-table fallback — a class in unit A's implementation section resolved
   inside an unrelated unit B that never `uses` A (and self-built the compiler
   into a SIGSEGV).  Impl-section symbols are now tagged IsImplPrivate and
   Lookup suppresses them whenever the viewing unit is not the owner.  Interface
   symbols and transitive interface-uses visibility are unchanged.

This also closes the corresponding latent leak: an implementation-section
declaration of any unit no longer resolves in units that do not use it.

Tests: cp.test.e2e.sepcompile gains TestNativeImplSectionClass_Compiles,
TestQBEImplSectionClass_Compiles, and TestImplSectionClass_DoesNotLeakCrossUnit.
Rationale recorded in docs/language-rationale.adoc.

All four fixpoints (QBE, native, internal-asm, warm-cache) green; full suite
3859 tests on both the QBE-built and native-built test runner.
2026-06-27 17:40:08 +01:00
Graeme Geldenhuys e7d236ddac fix(codegen-native): ARC the managed fields of a var-to-var record copy
A record-to-record assignment whose type has managed fields (string,
class, interface, dyn-array, or a nested record thereof) lowered to a bare
memcpy in the catch-all record-copy branch — no _StringAddRef on the
source and no _StringRelease on the destination's prior contents.  The two
records then shared one buffer at refcount 1, so mutating or churning
either side dropped the shared buffer to 0 and freed it under the other
(use-after-free, then `_StringRelease double-free (refcount < -1)` abort
once the heap slot was reused).

  var a, b: TPerson;          { TPerson = record Name: string; ... }
  a.Name := 'Alice';
  b := a;                     { b.Name shared a.Name's buffer, rc still 1 }
  a.Name := 'Bob';            { freed the buffer b still points at }
  WriteLn(b.Name)             { use-after-free }

Fix: in the record/static-array copy branch, when the record type is not
managed-clean, release the destination's managed fields and — unless the
source already transferred ownership (+1, guarded by NativeExprOwnsRef, as
the string/dyn-array assignment paths do) — retain the source's managed
fields, then memcpy.  Mirrors the existing record-field-store and
dyn-array-element-store copy paths.  QBE was already correct; native-only.

Test: cp.test.e2e.records TestRun_Record_VarToVarCopy_StringField_ARC —
copies a record with a string field, churns the source through 200 heap
reallocations, and asserts the copy still reads its own 'Alice-42' rather
than freed/reused memory.  Aborts before the fix, passes after.
2026-06-27 14:32:59 +01:00
Graeme Geldenhuys 1c250fb13e refactor(codegen): hoist symbol mangler to blaise.codegen (F5)
QBEMangle (TCodeGenQBE method) and NativeMangle (native free function)
applied the same metacharacter mapping: '<' ',' ' ' -> '_', drop '>', and
'$' '@' '^' -> '_D_' '_V_' '_P_'. Two copies of the symbol-naming rule is
a link-time-breakage risk if one side gains a case the other lacks.

Hoist a single CodegenMangle into blaise.codegen (distinct name, per the
F4 method-binding gotcha); both backends delegate. The shared version
keeps QBEMangle's clean-name fast-path (no per-character concat when no
metacharacter is present) and reads bytes via StrAt — so NativeMangle now
also uses the stage-stable StrAt instead of the Ord(S[i]) idiom the house
rules warn against.

Cross-backend dedup, no behaviour change: native .s AND QBE IR both
byte-identical on a generics + overload program (TList<Integer>,
TList<string>, Show$i/Show$S → TList_Integer_*, Show_D_i/Show_D_S). All
four fixpoints — which mangle the entire compiler's generic/overload
symbol set — and the full suite on both test runners are unchanged.
2026-06-27 12:05:27 +01:00
Graeme Geldenhuys 1e0b7fc46a refactor(codegen): hoist ARC ownership predicate to blaise.codegen (F2)
ExprOwnsRef (QBE backend) and NativeExprOwnsRef (native backend) were
byte-identical twins: a pure walk over the AST node + its resolved type
deciding whether an r-value leaves an ARC-managed value at refcount +1
that the consuming site must not AddRef again. Two drift-prone copies of
an ARC decision is exactly the failure mode that leaks/UAFs when one side
is updated and the other is not.

Hoist the single source of truth into blaise.codegen as ArcExprOwnsRef
(distinct name so an unqualified call inside a method cannot bind to a
same-named method — the F4 gotcha). Both backends keep their existing
free function as a one-line delegator, so every call site is unchanged.

Cross-backend dedup, no behaviour change: native .s AND QBE IR both
byte-identical on an ARC-heavy program (function-returned class, string
property read via getter, method call returning a class, dynamic-array
return). All four fixpoints and the full suite (QBE-built and native-built
test runners) unchanged.
2026-06-27 11:57:28 +01:00
Graeme Geldenhuys aa52ab6bef refactor(codegen-native): extract BuildArgSlotClasses (item 10)
The per-argument SysV slot classification — open-array and by-value
interface occupy two integer slots, a by-value Double/Single occupies one
xmm slot, everything else one integer slot — was duplicated verbatim in
EmitPopMethodArgsToRegs and EmitMethodOverflowLoad (the <=6-slot pop path
and the >6-slot overflow-load path). Hoist the classification loop into
BuildArgSlotClasses(AParams, AArgs, AList); each caller still prepends its
own Self slot (overflow-load adds slot 0, the pop path handles Self via
AIntBase) and consumes the list as before.

Pure dedup: byte-identical native .s verified on a method-call program
exercising float args, integer overflow (>6 slots), open-array, and
interface params. All four fixpoints and the full suite (QBE-built and
native-built test runners) unchanged.
2026-06-27 11:51:08 +01:00
Graeme Geldenhuys 5232ea7bc0 refactor(codegen-native): extract EmitVarAddr local/global address-of helper
The four-line "load the address of a named local-or-global into a
register" selector

    if Self.IsLocal(Name) then
      Self.Emit('leaq <VarOperand>, <reg>')
    else
      Self.EmitLeaqGlobal(Name, <reg>)

recurred verbatim at 20 sites across the field-access ladders, the
record-method receiver ladders (the Phase-2 item-12 target), the static-
array base loads, and the @-address-of paths. Collapse them into a single
EmitVarAddr(AName, ADstReg) helper.

Pure dedup, no behaviour change: EmitVarAddr emits exactly the same two
instruction forms the inlined selector did (and EmitLeaqGlobal already
routes threadvars through TLS, per the preceding fix). Verified byte-
identical native .s across threadvar and non-threadvar record/array/
method-call programs; all four fixpoints and the full suite (both QBE-
built and native-built test runners) unchanged.
2026-06-27 11:06:42 +01:00
Graeme Geldenhuys a86fb84eea fix(codegen-native): address record/aggregate threadvars via TLS
A threadvar of record (or any aggregate whose base address is taken via
leaq) was addressed with a bare `leaq Name(%rip)` for both direct field
access and method-call receivers, with no %fs:0/@tpoff. The symbol was
correctly placed in .tbss, but a %rip-relative reference to a TLS symbol
does not resolve to the per-thread slot, so all threads shared one static
slot and clobbered each other. Scalar threadvars were already correct
(VarOperand emits %fs:Name@tpoff); QBE was unaffected.

Route every global record/array base load through the TLS-aware
EmitLeaqGlobal helper instead of bare `leaq Name(%rip)`:
  - 16 field-access base ladders (FAE/FA/AFA.RecordName)
  - 4 record-method receiver sites (the >6-slot and <=6-slot ladders)
  - the offset-0 scalar-field-read fast path, which read the field
    directly off Name(%rip); now branches on IsThreadVarGlobal and falls
    through to EmitLeaqGlobal + indirect load for threadvars.

EmitLeaqGlobal emits the identical bare leaq for non-threadvar globals,
so codegen for ordinary globals is byte-identical (verified by .s diff)
and all four fixpoints are unchanged.

Tests: cp.test.e2e.threading gains
TestRun_ThreadVar_RecordField_PerThreadIsolation and
TestRun_ThreadVar_RecordMethod_PerThreadIsolation — 3 threads each stamp
a distinct id into a record threadvar (via field access / via method
call), spin re-reading, and assert no cross-thread clobber. Both fail
before the fix (native prints CORRUPT) and pass after.
2026-06-27 11:00:26 +01:00
Graeme Geldenhuys 180fbb62b4 refactor(codegen-native): extract EmitArgsToSlots (>6-slot call marshalling)
Phase 2 item 11 of the de-duplication plan — the largest single dedup.  The
">6-slot store-into-stack-slots" loop (write each logical argument to its
(I+1)*8(%rsp) slot, dispatching per kind: reload a hoisted akRecCall buffer
from its hoist depth / store a var-param address / store a width-adjusted xmm
bit pattern for a by-value float / store %rax for a plain scalar) was copy-
pasted verbatim at 6 call sites — the regular, metaclass-ctor, implicit-Self,
and proc-field >6-slot method paths.  Collapse into EmitArgsToSlots(AArgs,
AParams, AAllocSz, AHoistTotal, AHoistDepths, AHoistKinds); 7 call sites now
share it.

Risk gates preserved as the helper contract: all 6 sites used the same Self-at-
slot-0 base offset (Dest = (I+1)*8) and the same push-phase-free akRecCall
reload formula (AAllocSz + AHoistTotal - depth), so they parameterize cleanly —
no phase/index divergence to thread.

Behaviour-reconciling, but verified behaviour-preserving: native .s for a
>6-arg program (Self+8 int args, and a 7-slot mixed int/double/var-param call)
is byte-identical to pre-refactor HEAD; the native self-host fixpoint (which
compiles the whole compiler, exercising every >6-slot path including metaclass
and proc-field) is unchanged.  All four fixpoints pass; full suite OK (3853
tests) on both QBE-built and native-built test runners.
2026-06-27 10:23:41 +01:00
Graeme Geldenhuys 8e52f7769f refactor(codegen-native): extract EmitSretBufferSlideDown
Phase 1 item 7 of the de-duplication plan.  The post-call sret return-buffer
slide-down (`if HTotal > 0 then` reclaim the arg-hoist region and re-push the
fat pointer at (%rsp)) was byte-identical at the tail of all three
interface-sret return paths: EmitIntfSretCall, EmitIntfSretMethodCall, and
EmitClassIntfSretMethodCall.  Collapse into EmitSretBufferSlideDown(AHoistTotal).

Proven byte-identical: native .s for an interface-returning program (direct and
chained `MakeGreeter('Hi ').Greet('there')`, exercising the sret-return-with-
hoisted-arg slide) is identical to pre-refactor HEAD.  All four self-host
fixpoints pass; full suite OK (3853 tests) on both QBE-built and native-built
test runners.
2026-06-27 09:13:57 +01:00
Graeme Geldenhuys fdcce09254 refactor(codegen-native): extract EmitStaticElemScale for static-array indexing
Phase 1 item 5 of the de-duplication plan.  The read-path static-array
subscript scaling — `if SAT.LowBound <> 0 then subq $lowbound; imulq $elemsize`
applied to an index already in %rax — was spelled out at 7 sites, each casting
a different expression to TStaticArrayTypeDesc.  Collapse the body into
EmitStaticElemScale(ASAT) and pass the resolved type.

Only the read sites are touched: the static-array STORE paths compute the
element size from a precomputed DAElemType local rather than
ASAT.ElementType.RawSize(), so they are deliberately left as-is (the imulq
operand differs).

Proven byte-identical: native .s for a static-array program (non-zero low
bounds, integer + double + record-nested arrays, read and write) is identical
to pre-refactor HEAD.  All four self-host fixpoints pass; full suite OK (3853
tests) on both QBE-built and native-built test runners.
2026-06-27 09:09:07 +01:00
Graeme Geldenhuys 8a32c3ab57 refactor(codegen-native): extract EmitStmtList + try-frame EH helpers
Phase 1 (byte-identical) de-duplication of the native x86-64 backend, items
1-3 of Andrew's plan:

- EmitStmtList(AList): the compound-body inline idiom
  `for I := 0 to L.Count-1 do EmitStmt(TASTStmt(L.Items[I]))` was copy-pasted
  at 14 sites (try/finally/except bodies, loop bodies, program block, unit
  init).  Now one helper; nil-safe.  The element cast stays internal — the AST
  stores statements in TObjectList (raw-pointer slots), so the cast is
  inherent, just no longer repeated 14×.

- EmitTryFramePrologue(AFinallyBody, ALblExc, ALblTry): the setjmp try-frame
  entry (slot alloc, _PushExcFrame, FExcDepth bump, FFinallyStack push,
  _blaise_setjmp, testl/jnz/jmp) was byte-identical between EmitTryFinallyStmt
  and EmitTryExceptStmt, varying only in the FFinallyStack payload (FinallyBody
  vs nil) and the jnz target — now both parameters.

- EmitPopExcFrame: the frame-pop bookkeeping triplet (_PopExcFrame +
  Dec(FExcDepth) + FFinallyStack.Delete) appeared at 5 sites; the Dec and
  Delete must always travel with the pop, so they are emitted together.  The
  EmitExcUnwind loop deliberately keeps its bare _PopExcFrame (it must not
  touch the codegen-time depth bookkeeping).

Proven byte-identical: native .s for an EH-heavy program (try/finally,
try/except with handlers + else, nested try, repeat/continue, compound) is
identical to pre-refactor HEAD.  All four self-host fixpoints pass; full suite
OK (3853 tests) on both QBE-built and native-built test runners.
2026-06-27 08:49:14 +01:00
Graeme Geldenhuys c8faf1ed24 refactor(codegen): hoist record-return ABI classifier into shared blaise.codegen
The SysV/Win64 record-return classifier — ClassifyRecordReturn plus its five
leaf predicates (IsRecordManagedClean, IsRecordAllIntegerLeaves,
IsRecordAllFloatLeaves, IsRecordAllIntOrFloatLeaves, EightbyteIsSSE) — existed
as byte-identical twins in TCodeGenQBE and TNativeBackend (~145 lines each).
This is a drift-prone ABI decision table that both backends must keep in
lockstep; a divergence would silently misclassify a record return on one
backend only.

Move the logic to free functions in blaise.codegen (Recret* — renamed to avoid
colliding with the backend methods, since Blaise resolves an unqualified call
inside a method to the method, not the unit function).  Only the top-level
classifier consults the target OS (the Win64 aggregate rule), so it takes a
TTargetDesc parameter; the leaf predicates are target-independent pure type
walks.  Each backend keeps its method as a one-line delegator, so every
existing Self.X call site is unchanged.

Validated behaviour-preserving: native .s AND QBE IR are byte-identical
pre/post for a record-return program exercising every return class
(rcInt1/rcInt2/rcSSE1/rcSSE2/rcIntSSE/rcSSEInt/rcSret/rcWin64-clean).  All four
self-host fixpoints (QBE, native, internal-assembler, warm-cache) pass; full
suite OK (3853 tests) on both QBE-built and native-built test runners.

Implements item F4 of Andrew's native-codegen de-duplication plan — the
highest-value, lowest-risk cross-backend hoist.
2026-06-27 02:29:19 +01:00
Graeme Geldenhuys 6b99e4db79 fix(codegen-native): pin chained field value before releasing transient base
A deep field chain feeding a method/property call — MakeIt().A.B.Method() —
crashed the native backend.  The chained field read released the transient
function result immediately after the first field hop, but _ClassRelease at
refcount 0 runs _FieldCleanup, which frees the intermediate objects (A, then B)
that the rest of the chain still dereferences.  The terminal call then read
freed memory: a use-after-free that segfaulted (or, when the freed slot was not
yet reused, returned the stale value — non-deterministic).

This was the root cause of the TProcTypesOfObjectTests crash that killed the
TestRunner process and blocked ~975 alphabetically-later tests in the full
sequential suite (all of which pass individually).  QBE never released the
transient there, so it leaked instead of crashing — the bug was native-only and
only reproduced under a native-built test runner.

Fix: when the loaded field value is a class/interface reference borrowed from
the transient base, AddRef-pin it before the release so the cleanup's matching
release nets out and the object survives the chain.  This intentionally leaks +1
on the result, matching the QBE deep-chain transient leak already documented in
bugs.txt; correctness over a crash until the chain gains a deferred-release
mechanism.

Adds TE2EArcTests.TestRun_TransientDeepChainMethodCall_NoUseAfterFree and a
RunUnderValgrindNative base helper (the QBE-only RunUnderValgrind cannot see a
native UAF; the stdout value check alone is unreliable because the read is
non-deterministic, so native valgrind is the real guard).
2026-06-27 02:13:28 +01:00
Graeme Geldenhuys e08521a1f9 fix(codegen): release transient receiver after field-access load (both backends)
A function/getter result used as a field-access receiver was never
released, leaking +1 refcount per access on both QBE and native.
MakeCreature().HitPoints left the TCreature alive; TList<T>[I].Field
leaked via the Get getter.

Both backends now emit _ClassRelease for the transient base after the
field value is loaded. ExprOwnsRef/NativeExprOwnsRef extended to
recognise indexed-property subscripts (TStringSubscriptExpr wrapping a
ReadMethod-backed TFieldAccessExpr) as owning +1.

Dual-backend E2E regression test added
(TestDebug_ReceiverFieldAccess_NoLeak).

All 4 fixpoints verified (FIXPOINT_OK, NATIVE_FIXPOINT_OK,
NATIVE_INTERNAL_OK, WARMCACHE_FIXPOINT_OK).
2026-06-27 01:21:30 +01:00
Graeme Geldenhuys 7abf5b6255 fix(codegen-native): elide class-assignment AddRef when RHS owns +1
A function or method returning a class leaves its Result at refcount +1: the
callee's `Result := x` AddRef'd and the epilogue did not release Result. The
caller's assignment therefore consumes that transferred reference and must NOT
AddRef again. The native backend's plain class-variable assignment emitted
_ClassAddRef unconditionally, so `C := MakeIt()` (any object-returning call)
leaked exactly one reference per call.

Guard the AddRef with NativeExprOwnsRef, mirroring the tyString/tyDynArray
branches directly above it and the QBE backend's ExprOwnsRef elision (the QBE
path has always been leak-free here). Eliding is safe even for a borrowed return
(`Result := G`): the callee's Result assignment still AddRef'd, so the
transient genuinely owns +1 — verified no use-after-free when the source object
outlives the assignment.

Found via a TList<TCreature> + `L[I].HitPoints := ...` program reporting leaks
under --debug; reduced to a non-generic `C := MakeIt(); C := nil` minimal case.

NOTE: this fixes the assignment-site half. Object-returning call results used as
a field/method *receiver* or as a subscript getter result (e.g. L[I].Field) are
a separate, deeper gap — the native backend lacks the QBE backend's
statement-level deferred-release (PendingRelease/Flush) — addressed next.

cp.test.e2e.leakcheck.TestDebug_FuncReturnAssign_NoLeak pins both backends.
Full suite 3850 green; native fixpoint reproduces (and shrank the compiler's own
emitted asm by ~1400 lines of redundant retains).
2026-06-27 00:21:12 +01:00
Graeme Geldenhuys 948cd4ed1b feat(rtl): FreeBSD freestanding _start + minimal syscall leaf (Step 3)
Add the FreeBSD entry stub for a static, libc-free ET_EXEC:

- runtime.start.static.freebsd.pas — the FreeBSD sibling of
  runtime.start.static.linux. _start captures %rsp, aligns the stack, and calls
  the Pascal _BlaiseStartC, which parses argc/argv/envp off the kernel's initial
  stack (same layout as Linux), captures environ, calls main(argc, argv), and
  exits via the FreeBSD exit syscall. Deliberately minimal: no TLS/auxv-walk
  (a trivial program does no threadvar access) — that lands with the threads
  work in Step 4, mirroring how the Linux static start gained TLS.

- runtime.syscall.freebsd.pas — the minimum kernel leaf _start needs: _exit
  (SYS_exit = 1), write (SYS_write = 4, aliased from _sys_write), and the
  environ global. The full file/process/thread leaf grows here in Step 4. ABI
  notes record the FreeBSD differences from Linux: different syscall numbers and
  the carry-flag error convention (only relevant for the error-translating
  wrappers that arrive in Step 4).

Both units are standalone — nothing in the default Linux build graph uses them,
so the Linux RTL and self-hosting are unaffected; the FreeBSD RTL composition
links them at Step 5.

TLinkerE2ETests.TestLink_FreeBSDStart_StaticExecShape links the _start fixture
with the FreeBSD target and asserts the Strategy-B shape: e_type = ET_EXEC,
entry == _start, no PT_INTERP. (Uses LinkToBytes, not ReadFile — the latter
truncates at the ELF header's first NUL byte.)

Step 3 of docs/freebsd-x86_64-backend-design.adoc. Full suite 3850 green;
FIXPOINT_OK.
2026-06-26 23:58:08 +01:00
Graeme Geldenhuys 7af27a7db1 docs(freebsd): correct stale paths and refactor state in design doc
The RTL-unification work relocated the RTL units into the compiler tree and
replaced the per-host blaise_rtl.a archive with in-process source compilation;
the runtime now ships its own _start (commit 626ee4c9). Update the FreeBSD
backend design doc to match current reality:

- runtime/src/main/pascal/* -> compiler/src/main/pascal/* (unit relocation).
- blaise_thread.pas/blaise_mem.pas -> runtime.thread.pas/runtime.mem.pas; the
  setjmp .s file is now inline-asm runtime.setjmp.pas (all .s migrated).
- Startup section: runtime owns _start; the linker no longer scans host CRT
  objects (Scrt1.o/crti.o/crtn.o).
- Toolchain section: FindRTLArchive/blaise_rtl.a is gone; EnsureRTLObjects
  source-builds the RTL; the internal linker honours --target (Step 1).
- Step 4 syscall trampoline reframed as inline-asm Pascal (no .s files),
  mirroring the shipped Linux runtime.syscall.linux leaf.
- Step 5 reframed: end-state (no per-target .a) has landed, so it is a unit-list
  change in EnsureRTLObjects, not an archive change.
- Mark Steps 1 and 2 done with NOTE admonitions; fix the Step 2 class name
  (TPlatformLayoutFreeBSDX86_64) and pin it to FreeBSD 14.x; fix a Step 9 ->
  Step 8 cross-reference (there is no Step 9).
2026-06-26 23:48:14 +01:00
Graeme Geldenhuys a7ded4c228 feat(rtl): FreeBSD TPlatformLayout adapter (struct stat + OS constants)
Add rtl.platform.layout.freebsd with TPlatformLayoutFreeBSDX86_64, the
FreeBSD sibling of the Linux layout adapter. It supplies the FreeBSD 14.x
amd64 struct stat field offsets (st_mode@24, st_mtim.tv_sec@64, st_size@112,
sizeof=224 — all differing from Linux) and the O_CREAT/O_TRUNC/O_APPEND flag
bits (0x200/0x400/0x008, also differing); the remaining constants (S_*,
SEEK_*, CLOCK_REALTIME, WNOHANG) share Linux's values. Layout pinned to
FreeBSD 14.x and valid for 13.x (ino_t/dev_t widening landed in 12, stable
since).

The unit is a standalone sibling: nothing in the default (Linux) build graph
uses it, so the Linux RTL and self-hosting are unaffected. The FreeBSD RTL
archive composes it in place of rtl.platform.layout.linux (Step 5).

cp.test.platformlayout.freebsd adds 11 tests that instantiate the adapter
directly and assert each constant plus the three struct-stat accessors, the
latter by planting sentinels at the FreeBSD offsets in a 224-byte buffer and
reading them back — catching an offset typo on the Linux CI host without
needing FreeBSD emulation.

Step 2 of docs/freebsd-x86_64-backend-design.adoc. Full suite 3849 tests
green; FIXPOINT_OK.
2026-06-26 23:12:44 +01:00
Graeme Geldenhuys a545e86026 fix(stdlib): memory streams use the RTL allocator, not libc malloc/free
streams.pas bound malloc/free/realloc to libc by name, which left an undefined
reference under --static (libc-free) — the memory streams are reachable from the
compiler, so a static self-host link failed on 'free'.

Bind them to the RTL's own mmap-backed allocator
(_BlaiseGetMem/_BlaiseFreeMem/_BlaiseReallocMem) instead.  Same signatures, so
the call sites are unchanged; memcpy stays the C name (libc, or runtime.cstub
under --static).  The streams now allocate from the same heap as everything else
and carry no libc dependency.

With this, free()/malloc()/realloc() are resolved and the compiler links fully
--static.  Verified: blaise --static builds the compiler itself into a libc-free
ET_EXEC (no PT_INTERP, not a dynamic executable) that compiles + runs a program;
FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK; full suite 0 assertion
failures (residual EUnitNotFound errors are a local search-path artifact).
2026-06-26 19:30:42 +01:00
Graeme Geldenhuys 75628f3e20 feat(rtl): complete --static self-host support (chmod, strtod-free, TLS align)
Closes the gaps that blocked a --static (libc-free) build of the compiler itself:

- runtime.syscall.linux: add the chmod(2) syscall stub (SYS_chmod=90) defining
  the bare 'chmod' the internal linker's MakeFileExecutable imports.
- uSemantic: route RawStrToDouble through the RTL's pure-Pascal _StrToDouble
  (runtime.float) instead of libc's strtod, dropping the last libc dependency in
  the compiler's float-literal constant folding.  Mirrors the existing
  _DoubleToStr routing and is correct for both static and dynamic builds.
- blaise.linker.elf: align FTlsSize to FTlsAlign in both the static and dynamic
  TLS layout so TPOFF32 (sym - FTlsAddr - FTlsSize) matches the thread pointer
  the freestanding _start places at AlignUp(memsz, align).  Previously these
  diverged when the raw .tdata+.tbss sum was not a multiple of the TLS alignment,
  shifting every threadvar read by the padding (latent: today's 104-byte/8-align
  TLS happens to coincide).

With these, `blaise --static` can link the compiler itself; the resulting
binary is a fully static, libc-free ET_EXEC.
2026-06-26 19:15:25 +01:00
Graeme Geldenhuys a20a82c417 fix(asm): brace-comment strip must skip quoted strings
The asm brace-comment strip (added with the threads leaf to remove {...}
comments the lexer leaves inside asm blocks) ran over the WHOLE line, including
inside .ascii string literals.  The native backend emits string-const data as
.ascii directives, and the compiler's own codegen contains template strings with
literal braces (e.g. codegen.qbe.pas's 'data $__s%d = { w -1, w %d, w %d, b
"%s", b 0 }').  When the compiler was assembled by the internal assembler, the
strip ate the '{ ... }' span out of those .ascii literals, corrupting the
template strings.

The damage only showed when the compiler ran --emit-ir / GenerateIR: the
corrupted template produced garbage data lines (data $__s0 = <garbage>), so the
~1240 IR-assertion tests failed (TestHelloWorld_HasStrLitData etc.).  Compiled
user programs were unaffected (their own Format/strings come from freshly built
RTL), and the QBE-built compiler was unaffected (gcc assembles its .ascii), which
is why local QBE fixpoints stayed green while the CI native-internal TestRunner
went red.

Fix: skip over quoted strings in the brace strip, exactly as the adjacent
'#'-comment strip already does, so a brace inside a .ascii literal is left
intact.

Root-caused from the CI failure on ci/linux-direct-syscalls; the corruption is
visible as -clean but -garbled string data from a
native-internal-assembled compiler.
2026-06-26 18:30:53 +01:00
Graeme Geldenhuys e218b6978c fix(rtl,asm): address pre-landing review findings on the static leaf
Pre-landing review fixes, all in the direct-syscall / threads-leaf series:

- runtime.thread.static.linux + runtime.start.static.linux: reclaim the
  per-thread TLS block when clone(2) fails after BuildThreadTLS (previously only
  the stack mapping was unmapped, leaking one TLS mapping per failed
  pthread_create).  Add a symmetric FreeThreadTLS that recovers the mmap base
  from the thread pointer using the same alignment math BuildThreadTLS used.
- runtime.libc2.linux: system() now forwards `environ` to execve instead of
  nil, so the spawned /bin/sh sees PATH/HOME/etc. (was running with an empty
  environment under --static).
- runtime.libc2.linux: mkstemp loops getrandom until all 6 bytes are filled
  rather than ignoring a short read that would weaken the random suffix.
- runtime.libc.linux: drop execvp's dead HasSlash branch (both arms called the
  identical execve); keep the single call + the no-PATH-search note.
- runtime.thread.static.linux: correct the stale _clone_thread comment that
  claimed args pass through callee-saved regs (they are seeded on the child
  stack and popped).
- blaise.assembler.x86_64: in the asm brace-comment strip, splice without
  trimming mid-scan (a left-trim shifted the string under the scan index) and
  trim once at the end, so a second brace comment on the same line is still
  found.
- blaise.codegen.native.driver: replace a non-ASCII ellipsis in an added
  comment with '...' (ASCII-only source rule).

Verified: FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK; self-hosted
compiler builds clean (0 spurious lock prefixes); static threads binary runs
counter=80000 PASS (deterministic across runs); no unit/codegen/assembler/linker
test regressions.
2026-06-26 18:02:51 +01:00
Graeme Geldenhuys b03f84c4c3 feat(rtl): clone/futex threads leaf for --static (Linux)
Replaces the single-threaded no-op pthread_mutex stubs with a real, libc-free
threading leaf built directly on clone(2) and futex(2), so a static binary can
spawn worker threads — the last RTL piece needed for a libc-free static program
to do real multithreaded work.

runtime.thread.static.linux:
- pthread_create via raw clone(2): each thread gets its own mmap'd stack and a
  fresh per-thread TLS block (CLONE_SETTLS) so threadvar access (exception
  frames, the allocator) is per-thread.  A small asm trampoline seeds the start
  routine + arg onto the child stack, calls it, then exit(2) (thread-only).
- pthread_join via the CLONE_CHILD_CLEARTID futex word: the kernel clears it on
  thread exit and futex-wakes; join futex-waits on it, then munmaps the stack.
- 3-state futex mutex (Drepper): the caller's pthread_mutex_t buffer's first
  Integer is the futex word (0=unlocked, 1=locked, 2=contended); fast-path CAS,
  slow-path FUTEX_WAIT/FUTEX_WAKE.  sysconf is left to runtime.libc.linux.

runtime.syscall.linux: add the futex(2) syscall (SYS_futex, 6-arg, %rcx->%r10).

runtime.start.static.linux: capture the PT_TLS template at startup and expose
BuildThreadTLS so pthread_create can build each thread's TLS block the same way
_start builds the main thread's (variant II: TLS data then TCB self-pointer).

blaise.assembler.x86_64: add the xchg/cmpxchg encoders (xchgl/xchgq/cmpxchgl/
cmpxchgq, reg<->reg and reg<->mem) the futex mutex needs; strip brace comments
inside asm blocks in ParseLine (whole-line and trailing); and initialise
TParsedLine.HasLock to False so the lock prefix never carries between lines.

Verified: thrtest (8 worker threads x 10000 increments under the futex mutex)
prints counter=80000 PASS; QBE self-host FIXPOINT_OK.

The --static link path is currently blocked by a pre-existing self-host codegen
bug (spurious lock prefixes in large binaries built by a stage-2 compiler),
which is independent of this change and tracked separately.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys 3e4619a507 feat(rtl,linker): static TLS + mutex stubs — a libc-free static binary now RUNS
Completes the runnable-static-binary milestone: `blaise --static` now produces a
fully static, libc-free ELF (statically linked, no NEEDED libc.so.6, no
PT_INTERP) that EXECUTES correctly — `hello` prints, arithmetic/WriteLn work.

Static TLS (the last blocker — threadvar access via %fs faulted because %fs was
unset and static mode emitted no PT_TLS):
- linker LayoutSections now lays out .tdata/.tbss as a TLS block and sets
  FTlsAddr/FTlsSize/FTlsFileSize/FTlsAlign (previously only LayoutDynamic did,
  so static-mode TPOFF relocs resolved against 0).
- EmitExecutable emits a PT_TLS program header when the program has TLS, and
  LayoutSections reserves a 3rd phdr slot so the first section does not overlap
  and overwrite it.
- runtime.start.static.linux: rewritten with a Pascal core (_BlaiseStartC) that
  parses argc/argv/envp + the auxv, reads PT_TLS via AT_PHDR, mmaps + inits the
  TLS block, sets the thread pointer with arch_prctl(ARCH_SET_FS) (variant II:
  TP past the block, TCB self-pointer at %fs:0), then calls main.

runtime.thread.static.linux: no-op single-threaded pthread_mutex_* stubs
(correct for a single-threaded process).  pthread_create is intentionally
unprovided, so a thread-using static program (e.g. `uses classes`) fails to
link — the honest signal that the threads leaf (clone/futex) is the one
remaining piece.

Also fixed: runtime.start.static.linux had its `uses` in the implementation
section (Blaise only honours interface-section uses) which made syscall symbols
invisible; and em-dashes in comments tripped the lexer (known UTF-8 issue).

Full suite green (3829); dynamic native/QBE (incl. TLS + classes) unaffected.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys 7b6e275053 feat(rtl): single-threaded mutex stubs — static hello now links fully static
runtime.thread.static.linux: no-op pthread_mutex_init/lock/unlock/destroy
(correct for a single-threaded process; real clone/futex threads are deferred).
pthread_create is intentionally absent so a thread-spawning static program fails
to link rather than silently misbehaving.

With this the LAST libc leaf is resolved: a --static hello now links to a fully
static ELF (statically linked, no NEEDED libc.so.6, no PT_INTERP).  It does not
yet RUN — it faults on the first threadvar access because %fs (the thread
pointer) is unset and the linker emits no PT_TLS for static mode.  Static TLS
setup (PT_TLS + arch_prctl(ARCH_SET_FS) in _start + correct TPOFF) is the next
piece.

Full suite green (3829); dynamic native/QBE unaffected.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys 5c3e6c1527 feat(rtl): Tier 2 freestanding libc leaves for --static (Linux)
The logic-bearing (non-syscall) leaves.  A --static link now resolves
abort/__cxa_atexit/exit/gmtime_r/localtime_r/timegm/mkstemp/system; the only
remaining gap is the pthread_mutex_* stubs (Tier 3).

New runtime.libc2.linux:
- atexit registry: __cxa_atexit records (func,arg) LIFO; the bare `exit`
  (main's epilogue) runs them then exit_group.  `exit`/`_exit` moved out of
  runtime.syscall.linux (which keeps only the raw _exit that runs no handlers).
- abort: kill(getpid, SIGABRT) then exit_group(134).
- gmtime_r/localtime_r/timegm: freestanding civil-calendar math (UTC;
  days_from_civil / civil_from_days).  No timezone DB yet — localtime == UTC.
- mkstemp: getrandom-named O_CREAT|O_EXCL|O_RDWR 0600 file, retry on EEXIST.
- system: fork + execve /bin/sh -c + wait4.

Full suite green (3829); dynamic native/QBE unaffected.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys a9db1b0b0a feat(rtl): Tier 1 syscall-backed libc leaves for --static (Linux)
Adds the syscall-backed half of the libc replacement.  A --static link now
resolves mremap/getcwd/fork/execvp/waitpid/time/getenv/sysconf; the remaining
gap is the freestanding-logic leaves (abort/__cxa_atexit/gmtime_r/localtime_r/
timegm/mkstemp/system, Tier 2) and the mutex stubs (Tier 3).

- runtime.syscall.linux: + raw mremap/fork/execve/wait4/sched_getaffinity/
  getrandom/arch_prctl/kill/sys_time/sys_getcwd (4th syscall arg %rcx->%r10 for
  the >=4-arg ones).
- runtime.libc.linux (new): the libc-shaped wrappers that need more than a raw
  syscall — getcwd (return buf/nil), getenv (walk environ), time, waitpid
  (wait4 + NULL rusage), execvp (execve; direct path, PATH search deferred to
  when a bare-name exec appears), sysconf(_SC_NPROCESSORS_ONLN) via
  sched_getaffinity + popcount.  Owns the `environ` global.
- runtime.start.static.linux: capture environ = &argv[argc+1] from the initial
  process stack into the global before calling main.
- EnsureRTLObjects: link runtime.libc.linux in --static builds.

Full suite green (3829); dynamic native/QBE unaffected.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys be0e519084 feat(rtl): direct-syscall kernel-leaf foundation + --static link mode (Linux, WIP)
First increment of the libc-free direct-syscall RTL (Linux as the proving
ground for FreeBSD; docs/linux-syscall-migration.adoc to follow).  The default
build is unchanged — libc-backed dynamic ELF; everything here is behind the new
--static flag.

New kernel-leaf units (selected by EnsureRTLObjects when --static):
- runtime.syscall.linux — raw `syscall` stubs DEFINING the bare POSIX names the
  RTL imports (open/read/write/lseek/close/fstat/stat/mkdir/rmdir/unlink/rename/
  chdir/getpid/dup2/pipe/mmap/munmap/nanosleep/clock_gettime/exit/_exit).
  `write`/`exit` are Pascal-reserved, so their Pascal names are _sys_* and the
  asm body emits a bare label alias.  4th syscall arg moved %rcx->%r10.
- runtime.cstub — freestanding memcpy/memset/memcmp/strlen (no Char type; PChar
  byte indexing).
- runtime.start.static.linux — freestanding _start (argc/argv off %rsp, call
  main, exit_group) replacing the libc __libc_start_main entry.

Wiring: --static -> TBackendOpts.Static; EnsureRTLObjects swaps runtime.start
for runtime.start.static.linux and adds the syscall+cstub leaves; the native
internal linker calls SetDynamic(False) for a non-PIE ET_EXEC.

Internal linker static-mode fixes (these are correct in any ET_EXEC, not just
PIE — they were wrongly gated to dynamic-only):
- R_X86_64_64 against a defined symbol resolves to the symbol's absolute address
  (no dynamic reloc); only PIE/dynamic mode also emits the .rela.dyn RELATIVE.
- R_X86_64_TPOFF32 (Local-Exec TLS) resolves at link time — it IS the static TLS
  model. Updated TestReloc_Quad64_* to assert the now-correct resolution.

A --static link currently reaches symbol resolution and reports the remaining
libc leaves to implement (mremap/getcwd/fork/execvp/waitpid/time/getenv/sysconf/
gmtime_r/localtime_r/timegm/__cxa_atexit/abort/mkstemp/system/pthread_mutex_*) —
the next increments.  Full suite green (3829); dynamic native/QBE unaffected.
2026-06-26 15:10:15 +01:00
Graeme Geldenhuys 93ac17bb62 fix(codegen-qbe): pad FFI aggregate types to match record field offsets
A record passed BY VALUE to a function (e.g. `const AParsed: TParsedLine`)
lowers to a QBE `:_ffi_<Name>` aggregate type whose field list was built by
FlattenRecordToAggLetters inlining each field's type letter back-to-back, with
nested records expanded letter-by-letter. That dropped the per-field and nested-
record tail padding, so QBE laid out the struct SMALLER than Blaise's record and
with shifted field offsets.

For TParsedLine this put the trailing HasLock byte at QBE offset 244 (struct size
248) while the function body reads it at the real Pascal offset 252 (size 256).
A by-value read at +252 then lands 4 bytes PAST the passed struct, in caller
stack garbage. When that garbage was non-zero, the assembler's
`if AParsed.HasLock then CBEmit($F0)` fired on EVERY instruction, prepending a
spurious lock prefix — `lock ret` is illegal, so the produced binary SIGILL'd.

This silently corrupted large native binaries built by a self-hosted compiler
(the assembler unit is where it bit hardest), and it was the real cause of the
"--static binaries are poisoned" and "stage-2 TestRunner SIGILLs" symptoms.
It hid from all four fixpoints: the QBE fixpoint compares IR text (the IR was
always correct — `loadub +252` is right; QBE miscompiled the by-value param
layout), and the native fixpoints pipe through the EXTERNAL assembler.

Fix: FlattenRecordToAggLetters now walks fields by their real TFieldInfo.Offset,
inserting explicit `b` byte padding before each field and after the last one up
to TotalSize, so the QBE aggregate's field offsets and size match the Pascal
record exactly. Nested records / static arrays / sets are emitted as their whole
byte span (the by-value ABI only needs correct size and field positions).

Verified: QBE-built compiler now produces 0 spurious lock prefixes; static
threads binary runs (counter=80000 PASS); compiler/target/blaise self-compiles
clean; FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK.
2026-06-26 15:03:08 +01:00
Graeme Geldenhuys af59423f11 fix(codegen): allocate sret buffer for discarded record-returning method calls
Calling a record-returning method as a statement (result discarded) omitted
the hidden sret pointer required by the SysV x86-64 ABI.  Self was passed
in %rdi where the sret buffer should be, so the callee wrote the returned
record through the object pointer, corrupting its fields.

Semantic pass: set ResolvedReturnTypeDesc on TMethodCallStmt at all three
class-method resolution sites so both backends can detect the record return.

QBE backend: allocate a temporary buffer via alloc8, zero it, route through
EmitRecordReturnCallSite, then release any managed fields (strings, classes,
interfaces, dynamic arrays).  Both the regular and ObjExpr receiver paths.

Native backend: reserve an aligned stack buffer, shift Self from %rdi to
%rsi, place the sret pointer in %rdi, and clean up managed fields after the
call.  Handles the <=6-slot path.
2026-06-26 14:13:10 +01:00