fix(compiler): incremental unit-mode crash (nil Prog.SymbolTable)

Compiling a UNIT (top source is a `unit`, so the pipeline runs in unit-mode and
Prog stays nil) via the default incremental path segfaulted: the incremental
worker setup unconditionally read `Prog.SymbolTable` to seed each dep worker,
dereferencing nil.  In unit-mode the symbol table comes from the semantic pass,
not a program node.  This is exactly the invocation the runtime Makefile drives
(`blaise --source X.pas --output X.o` per RTL unit), so it broke `make` in
runtime/ once incremental became the default; it was a latent pre-existing bug
in the opt-in incremental path (reproduces at de0ea5d with --incremental).

Fix: seed the worker symbol table from Semantic.GetSymbolTable() when Prog is
nil, matching the non-incremental unit-mode path.

Also pin the runtime Makefile to --no-incremental.  That Makefile is itself a
hand-managed separate-compilation system (one object per unit + an explicit
archive list); the compiler's incremental mode would additionally write
per-dependency side-effect objects and skip inlining a used unit's bodies, but
those side-effect objects are not in the archive list — so a cross-unit symbol
(e.g. typeinfo_TRtlPlatform, referenced by the derived TRtlPlatformPosix in a
different unit) was left undefined at link.  Whole-program per unit keeps each
unit object self-contained.

Regression test (cp.test.e2e.sepcompile.pas): compile a unit that uses another
unit in unit-mode via the default incremental path, then build and run a
program over the emitted object.
This commit is contained in:
Graeme Geldenhuys 2026-06-20 02:04:58 +01:00
parent 47ca10d7d3
commit 13a8a39af0
3 changed files with 108 additions and 2 deletions

View file

@ -789,7 +789,14 @@ begin
Worker := TCompileWorker.Create(True);
Worker.WorkUnit := TUnit(Units.Items[I]);
Worker.Iface := TUnitInterface(UnitIfaces.Items[I]);
Worker.SymTable := Prog.SymbolTable;
{ In unit-mode (the top source is a `unit`) Prog is nil the symbol
table comes from the semantic pass, not a program node. Using
Prog.SymbolTable there dereferences nil and crashes the incremental
worker setup. }
if Prog <> nil then
Worker.SymTable := Prog.SymbolTable
else
Worker.SymTable := Semantic.GetSymbolTable();
Worker.OPath := UnitOPath;
Worker.Driver := WorkerDriver;
Worker.Opts := Opts;

View file

@ -49,6 +49,13 @@ type
procedure TestDuplicateExternalAcrossUnits_Compiles;
procedure TestNativeIncremental_MultiUnitClass_Compiles;
procedure TestNativeNoIncremental_MultiUnitClass_Compiles;
{ Regression: compiling a UNIT (unit-mode, top source is a `unit`) that
USES another unit, with the default incremental path, segfaulted the
incremental worker setup read Prog.SymbolTable, but in unit-mode Prog is
nil (the table comes from the semantic pass). This is the exact path the
runtime Makefile drives (every RTL unit is compiled `--source X.pas
--output X.o`), so it broke `make` in runtime/. }
procedure TestNativeIncremental_UnitUsesUnit_Compiles;
end;
implementation
@ -579,6 +586,90 @@ begin
AssertEquals('use_shapes_ni stdout', 'shape:box' + #10, Captured)
end;
procedure TSepCompileTests.TestNativeIncremental_UnitUsesUnit_Compiles;
{ Compile a UNIT that uses another unit, in unit-mode (--source <unit>.pas
--output <unit>.o), via the default incremental path. This is the runtime
Makefile's exact invocation and used to segfault (nil Prog.SymbolTable in the
incremental worker setup). A program that uses the compiled unit must then
build and run, proving the per-unit object is correct. }
const
BaseSrc =
'''
unit BaseU;
interface
type TFoo = record A: Integer; end;
function MakeFoo(N: Integer): TFoo;
implementation
function MakeFoo(N: Integer): TFoo;
begin Result.A := N end;
end.
''';
DerivedSrc =
'''
unit DerivedU;
interface
uses BaseU;
function Doubled(N: Integer): Integer;
implementation
function Doubled(N: Integer): Integer;
var F: TFoo;
begin F := MakeFoo(N); Result := F.A * 2 end;
end.
''';
ProgSrc =
'''
program UseDerived;
uses DerivedU;
begin
WriteLn(Doubled(21))
end.
''';
var
BasePas, DerivedPas, DerivedObj, ProgPas, ProgBin: string;
Captured: string;
Rc: Integer;
begin
if not ToolchainAvailable() then
begin
Fail('toolchain missing — qbe or RTL not found');
Exit
end;
if not FileExists(BlaisePath()) then
begin
Fail('blaise binary missing at ' + BlaisePath());
Exit
end;
BasePas := FScratch + '/BaseU.pas';
DerivedPas := FScratch + '/DerivedU.pas';
DerivedObj := FScratch + '/derivedu.o';
ProgPas := FScratch + '/use_derived.pas';
ProgBin := FScratch + '/use_derived';
WriteFile(BasePas, BaseSrc);
WriteFile(DerivedPas, DerivedSrc);
DeleteFile(DerivedObj);
{ Unit-mode incremental compile: this is the call that crashed. }
Rc := RunBlaise(['--source', DerivedPas, '--output', DerivedObj,
'--backend', 'native',
'--unit-path', FScratch], Captured);
AssertEquals('blaise(DerivedU unit) exit code (out: ' + Captured + ')', 0, Rc);
AssertTrue('DerivedU.o exists', FileExists(DerivedObj));
{ The emitted unit object must be usable: build + run a program over it. }
WriteFile(ProgPas, ProgSrc);
Rc := RunBlaise(['--source', ProgPas, '--output', ProgBin,
'--backend', 'native',
'--unit-path', FScratch], Captured);
AssertEquals('blaise(use_derived) exit code (out: ' + Captured + ')', 0, Rc);
AssertTrue('use_derived exists', FileExists(ProgBin));
Rc := RunBinary(ProgBin, Captured);
AssertEquals('use_derived exit code', 0, Rc);
AssertEquals('use_derived stdout', '42' + #10, Captured)
end;
initialization
RegisterTest(TSepCompileTests);

View file

@ -25,7 +25,15 @@ LIB = $(OBJ_DIR)/blaise_rtl.a
# driver finds it automatically next to itself.
COMPILER_BIN = ../compiler/target
BLAISE = $(COMPILER_BIN)/blaise
BLAISE_FLAGS =
# This Makefile IS a hand-managed separate-compilation system: it compiles each
# RTL unit to its own object and archives an explicit object list. The
# compiler's own incremental mode (now the default) would, on top of that, write
# per-dependency side-effect .o files next to each output and skip inlining a
# used unit's bodies — but those side-effect objects are not in the archive's
# object list, so a cross-unit symbol (e.g. typeinfo_TRtlPlatform, referenced by
# the derived TRtlPlatformPosix in another unit) ends up undefined at link.
# Force whole-program compilation per unit so every unit object is self-contained.
BLAISE_FLAGS = --no-incremental
# Assembly sources — platform-specific, no C compiler needed.
ASM_OBJS = $(OBJ_DIR)/blaise_setjmp_x86_64.o \