Assert() halts instead of raising EAssertionFailed
- Type: bug / FPC-parity divergence — Track P (compat tag)
- Found: 2026-08-02, prompted by the question "don't we have assert in
Pascal already?" while adding NilPy's
assertstatement ([[bug-nilpy-assert-statement-not-supported]], fixed in74b8bc37d). - Measured against FPC directly, not reasoned.
The divergence
{$mode objfpc}{$H+}
program a;
uses sysutils;
begin
try
Assert(1 = 2, 'boom');
except
on E: Exception do WriteLn('caught: ', E.ClassName, ': ', E.Message);
end;
WriteLn('still running');
end.
| output | exit | |
|---|---|---|
FPC (-Sa) |
caught: EAssertionFailed: boom (a.pas, line 6) then still running |
0 |
| pxx | Assertion failed: boom |
227 |
__pxxAssert (compiler/builtin/builtin.pas) does writeln + Halt(227)
unconditionally. So the handler never runs and everything after the try is
lost.
Why this is the interesting kind of bug
It is the same failure family as three NilPy bugs fixed the same night: a
diagnostic path that ABORTS the process where the reference implementation
RAISES, so a try/except around it cannot run its handler. It was invisible
here because the abort looks like correct behaviour — the message is right and
the exit code is even FPC's own 227.
The 227 is what makes it plausible: that IS what FPC does — but only when SysUtils is NOT used. With SysUtils, FPC's behaviour changes.
Cause, and why the fix is already half-present
FPC's mechanism is a hook: System.AssertErrorProc is a procedure variable that
defaults to "print and run-error 227", and SysUtils installs its own which
raises EAssertionFailed. That is the entire difference.
We already have the destination:
lib/rtl/sysutils.pas:107 declares EAssertionFailed = class(Exception) end;
— but nothing raises it, and there is no AssertErrorProc anywhere in the tree.
So the fix is to reproduce FPC's shape rather than invent one:
- add an
AssertErrorProc-style hook beside__pxxAssertin builtin __pxxAssertcalls the hook when set, else keeps today's print + Halt(227)sysutilssets it at unit init to raiseEAssertionFailedwith the message
That keeps a no-sysutils program byte-identical to today (which is also correct FPC behaviour) and fixes only the case that currently diverges.
Second, smaller divergence found at the same time
FPC compiles assertions out unless -Sa / {$ASSERTIONS ON}; pxx always
evaluates them. That is the lax direction (we run a check FPC skipped) and does
not produce a wrong value, so it is noted rather than filed separately — but if
{$ASSERTIONS} is ever implemented, these two want doing together.
Gate — note the pin
__pxxAssert lives in compiler/builtin/, so this change makes gate.sh's
self-host fixedpoint report A != B until make stabilize + make pin
([[project_builtin_change_needs_repin_for_gate_fixedpoint]]). That is not a
regression, it is the frozen-builtin boundary — budget for the repin.
Test: the program above, plus the no-sysutils case still halting with 227, plus
Assert with no message, plus a passing assertion.
Resolution (2026-08-05)
Implemented as the ticket prescribed — FPC's shape reproduced, not a new one:
TAssertErrorProc+ anAssertErrorProcvariable beside__pxxAssert(compiler/builtin/builtin.pas).__pxxAssertcalls the hook when assigned; otherwise keeps today's print +Halt(227).sysutilsinstallsSysAssertErrorat unit init, raisingEAssertionFailed— which the RTL already declared and nothing raised.
Step 3 sits directly beside PXXOverflowHook / PXXDivZeroHook /
PXXRangeErrorHook / PXXIoErrorHook, which are the same ErrorProc design
already in the tree. That is what made this four lines rather than a redesign.
Measured against FPC — all four cases
| case | FPC | pxx after |
|---|---|---|
| failing assert, no sysutils | prints, exit 227 | prints, exit 227 |
| passing assert, no sysutils | runs on, exit 0 | same |
| passing assert, with sysutils | runs on, exit 0 | same |
| failing assert, with sysutils | caught as EAssertionFailed, exit 0 |
caught as EAssertionFailed, exit 0 |
The no-sysutils 227 is preserved deliberately: it is not a leftover, it is FPC's behaviour in that configuration, and the ticket was explicit that only the diverging case should change.
The pin note did not apply
The ticket budgeted for a repin because __pxxAssert lives in
compiler/builtin/. Not needed: tools/selfhost_fixedpoint.sh converges in 2
rounds from pinned and agrees with compiler/pascal26. Recording that so the
next builtin change does not assume a repin is automatic either way — it is
worth measuring rather than budgeting for.
One cosmetic divergence left, deliberately
FPC appends the source position to the message — boom (a.pas, line 6) — and
pxx does not. That is message TEXT, not behaviour, and outside what this ticket
asked for. Noted rather than folded in; it wants {$ASSERTIONS} and the
compile-out semantics the ticket's own "second, smaller divergence" section
describes, and the three are one piece of work.
testmgr --tier native 1167/1167 pass. Locked in as
test/test_assert_raises_with_sysutils.pas (the halting half cannot be a test —
the harness has no non-zero-exit form).
Log
- 2026-08-05 — resolved, commit ccdc60320.