← board

Assert() halts instead of raising EAssertionFailed

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:

  1. add an AssertErrorProc-style hook beside __pxxAssert in builtin
  2. __pxxAssert calls the hook when set, else keeps today's print + Halt(227)
  3. sysutils sets it at unit init to raise EAssertionFailed with 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:

  1. TAssertErrorProc + an AssertErrorProc variable beside __pxxAssert (compiler/builtin/builtin.pas).
  2. __pxxAssert calls the hook when assigned; otherwise keeps today's print + Halt(227).
  3. sysutils installs SysAssertError at unit init, raising EAssertionFailed — 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