I just ran into a very frustrating issue in Delphi 10.4.1 with long-standing, basic functionality that is now broken in certain circumstances. Fortunately there is a work-around in those certain circumstances, but it’s not pretty.
The Problem: Re-Raising an exception causes an immediate Access Violation error at the raise
statement (in certain circumstances).
An MCVE (Minimum, Complete/Compilable, Verifiable Example) is difficult to provide, as a stand-alone example. But the real-world scenario in which I ran into this is itself a CVE, if not entirely “Minimal”.
This scenario was in a test case in the Smoketest unit tests for my Multicast event library, specifically in this test:
procedure TMulticastNotifyTests.AllHandlersAreCalledIfAnExceptionIsRaisedByOne;
begin
Test.RaisesException(EHandlerExceptions);
sut.Add(NotifyException);
sut.Add(NotifyA);
try
try
sut.DoEvent;
except
on e: EHandlerExceptions do
begin
Test('e.Count').Assert(e.Count).Equals(1);
Test('e.Exceptions[0] is Exception').Assert(e[0] is Exception);
raise;
end;
end;
finally
Test('fCallCountA').Assert(fCallCountA).Equals(1);
end;
end;
sut
is a unit variable referencing a TMulticastNotify
event instance, initialised (and reset) by Setup
and Teardown
methods that executing before and after each test.
The intent of this test is as follows:
ARRANGE
- Add two handlers to the sut (subject under test), which is a multicast
TNotifyEvent
(TMulticastNotify
).- The first handler raises an exception when called
- The second handler increments a call count variable
ACT
- The event is fired
ASSERT:
- An
EHandlerExceptions
exception is raised by the multicast event. (This is an aggregate exception type that captures any exceptions raised by any handlers). - The
EHandlerExceptions
exception has captured a single exception of classException
. - All handlers are called, despite the exception raised by the first handler (tested by ensuring that
fCallCountA
has been incremented).
For the first of these test outcomes, the test uses a new feature in Smoketest 2.1 that describes an expected exception in the initial arrange phase.
The test must then catch that exception in order to test it for the expected properties. The exception is then re-raised to allow the stated, expected exception to be satisfied. The Test.RaisesException()
could have been replaced by tests in the exception handler, avoiding the re-raise, but at the expense of (a little) greater complexity (imho).
Finally, the call count is tested to ensure that even though the first handler raised an exception, the second handler was also successfully called.
And this test executes and passes successfully in all versions of Delphi from 7 thru 10.3. It also executes and passes in an x64 build with 10.4.1.
But in an x86 build with 10.4.1, the test fails because the raise
statement in the e: EHandlerExceptions
handler crashes with an Access Violation.
We can see in this log output from the Azure DevOps build pipeline, that two tests exhibit this behaviour. The second test case is similar and suffers the same problem as a result.
Ordinarily this would be an ERROR
result, rather than FAILED
, but because the test has the expectation of some exception being raised, the test is considered to have failed because although an exception was indeed raised, it was not of the expected class.
I have not yet gotten to the bottom of why this happens. However, I came upon this discussion thread in which someone experiencing the same problem mentioned that passing the captured exception as a parameter to a procedure ‘fixed’ the problem.
So I applied that to my case as follows:
procedure TMulticastNotifyTests.AllHandlersAreCalledIfAnExceptionIsRaisedByOne;
{$ifdef DELPHI10_4}
// In 10.4.1, re-raising the exception crashes immediately with an AV exception.
// This is a compiler bug that can be avoided by passing the capture exception (e)
// to a procedure (for some reason).
procedure DoTestsInProcToAvoidCompilerBugWhenReraisingTheException(e: EHandlerExceptions);
begin
Test('e.Count').Assert(e.Count).Equals(1);
Test('e.Exceptions[0] is Exception').Assert(e[0] is Exception);
end;
{$endif}
begin
Test.RaisesException(EHandlerExceptions);
sut.Add(NotifyException);
sut.Add(NotifyA);
try
try
sut.DoEvent;
except
on e: EHandlerExceptions do
begin
{$ifdef DELPHI10_4}
DoTestsInProcToAvoidCompilerBugWhenReraisingTheException(e);
{$else}
Test('e.Count').Assert(e.Count).Equals(1);
Test('e.Exceptions[0] is Exception').Assert(e[0] is Exception);
{$endif}
raise;
end;
end;
finally
Test('fCallCountA').Assert(fCallCountA).Equals(1);
end;
end;
And it worked! \o/
Of course, this is far from ideal, resulting in duplication of the test conditions not to mention behaviour specific to a particular compiler version. I could have adopted a consistent approach for all compiler versions of course, but decided not to as providing the best opportunity to identify whether the problem persists in Delphi 10.5 (or a compiler update to 10.4 with a different version identifier).
Reproducability
Frustratingly, an MCVE console app that simply raises an exception, catches it and re-raises it does not exhibit the behaviour. So is it something about my Smoketest framework? The multicast event implementation?
Well, the person experiencing this problem in that praxis thread isn’t using either of those things. But perhaps there is something in common between our two scenarios.
The multicast event implementation has some behaviour to capture the exceptions from the registered handlers, calling AcquireExceptionObject
to ensure that the exceptions “live” beyond the lifetime of the encapsulating handler (and are then freed when the containing EHandlerExceptions
exception is itself destroyed).
But this is not especially exotic behaviour, nor does it cause any problems in any other version of Delphi or the 64-bit compiler of 10.4. For completeness, here’s the test run output for that from the same build pipeline:
And a summary of the results across the entire pipeline:
The warnings in the PreXE2 job come from Delphi 7, caused by the mere existence of “deprecated” methods in the Smoketest framework (not triggered in any other compiler version), which I still need to resolve to remove this ‘noise’ from my pipelines.
The multiple jobs for PreXE2, XEx86, XEx64 etc are due to the way I have “batched” my builds to take advantage of multiple build agents to get parallel builds and speed things up.
I would love to hear from anyone who has an explanation for why this problem only manifests in some circumstances and why simply passing the captured exception as a param to local procedure does enough to keep the compiler on the straight and narrow.