Catch Me If You Can - Part II

It has been quite some time since I wrote the Catch Me If You Can post about the inability of LLVM-backed compilers to catch non-call hardware exceptions. In other words, hardware exceptions raised by code written in the same block as the try...except exception handler.

Since then, more LLVM-backed compilers have been added to Delphi, and with those, more reasons to change common coding practices... but still, very few developers know about the issue.

The original article covered only try...except exception handlers, and exploring what exactly happens with implicit and explicit try...finally handlers was left as an exercise to the readers and to some future, now long overdue, post.

And Now: All Hell Breaks Loose

When you read about non-call hardware exceptions not being caught by an immediate try...except block, the implications might not look too serious. After all, while plenty of code can raise exceptions, try...except handlers are not that frequently used in places, as most exceptions will be left to bubble up at the next code level.

This is good, you may think, as such exception handlers will be able to catch exceptions raised within other procedures and methods and you might not have that many places where you would need to wrap your code within another procedure or method call, or add additional checks that would prevent hardware exceptions happening in the first place.

However, the same reason why hardware exceptions can escape try...except blocks applies to try...finally blocks, and when that happens, the consequences are way, way more serious.

Explicit try...finally blocks

Let's take another look at the example from the previous article:

type TFoo = class(TObject) public procedure Foo; virtual; end; procedure TFoo.Foo; begin end; procedure Escape; var Foo: TFoo; begin Foo := nil; try // calling a virtual method on nil reference raises an Access Violation exception Foo.Foo; except Log.d('CAUGHT'); end; end;

The issue with the above Escape procedure is that the exception will bubble up, and will not be caught by the exception handler, so any code within that handler will not be executed. You may think that the worst that can happen is that instead of showing some nice error message to the user that some operation failed and why, your application will show the default message box brought up by the application-level exception handler.

Now, let's modify that code and add a try...finally instead.

procedure Escape; var Foo: TFoo; Bar: TObject; begin Foo := nil; Bar := TObject.Create; try // calling a virtual method on nil reference raises an Access Violation exception Foo.Foo; finally Bar.Free; // this line is never called end; end;

The consequences of the above code will be that the code in the finally section will not run, causing the memory leak. While in this example the memory leak is rather small, even small leaks can accumulate and cause severe memory fragmentation in long-running processes. And of course, in real life, that leaked memory could have been much larger.

This is bad, and you would definitely need to examine and fix all code where a hardware exception might escape a try...finally block. Depending on the size of your codebase, this could mean days and weeks of work.

Implicit try...finally blocks

If you think that things cannot get any worse, think again. Besides explicit try...finally blocks, Delphi also inserts implicit, hidden, try...finally blocks to perform appropriate cleanup for any managed type. And just because you don't see them, that does not mean they don't exist, and they will also be totally broken if the non-call hardware exception occurs within the code they are meant to protect.

This means that every string, every dynamic array, every anonymous method, every interface, will leak in such a scenario.

procedure Escape; var Foo: TFoo; Bar: TObject; Baz: IInterface; begin Foo := nil; Baz := TInterfacedObject.Create; Bar := TObject.Create; try // calling a virtual method on nil reference raises an Access Violation exception Foo.Foo; finally Bar.Free; // this line is never called end; end; // cleanup code in the epilogue is never called

In the above example, not only will the Bar object instance leak, but the Baz instance will leak, too.

procedure Escape; var Foo: TFoo; Baz: IInterface; begin Foo := nil; Baz := TInterfacedObject.Create; // calling a virtual method on nil reference raises an Access Violation exception Foo.Foo; end; // cleanup code in the epilogue is never called

In the above code, we only have implicit cleanup, which will not happen. If we replaced an interface reference with the most commonly used type—a string—all those strings would leak if the hardware exception happened directly within the Escape procedure code.

So, not only can you not simply focus on locating and fixing the problematic code within various try... blocks, but you need to pay attention to implicit cleanup code that may be less obvious. And this also makes wrapping code within another procedure or method harder, as you may need to cut the code up into multiple methods instead of a single one.

On the Bright Side...

For the vast majority of applications, the most common hardware exceptions will be access violations and floating point exceptions. And with floating point exceptions being masked, you don't have to worry much about those. The rest of the hardware exceptions will commonly come from some lower-level code or API that will already be wrapped inside some procedure call, and as such, will not cause you any trouble.

So, most of the code you need to change will be to make sure that you don't access nil instances where you can expect one, and rely on exception handling as part of the normal code workflow in such a situation.

This leaves unexpected access violation errors stemming from some serious issues in your code, which will require code inspection and fixing the problem anyway. While it would still be better if you could properly catch those and have a more controlled application shutdown and better error reporting, in real life, unless you have an extremely buggy application, you will rarely see an escaping hardware exception making your application go haywire.

That is another reason why many developers are not aware of this issue. It is simply because they have never encountered such a situation in real life.

If you are writing code for LLVM-backed compilers, you should pay more attention to this problem, and make sure that code that could cause hardware exceptions either avoids causing one in the first place (accessing nil), or that you wrap such code within a separate procedure or method which will not be inlined and will not have any local implicit or explicit exception handling blocks.

But chances are that most of your code will not require any special adjustments.

You can read more about the underlying LLVM issue at https://github.com/llvm/llvm-project/issues/1641

Comments

  1. Thanks! I'm thankful that I spend my life in Windows, but even though you say most code will not require special adjustments it still spooks the heck out of me to ever use their LLVM-backed compilers based on this.

    ReplyDelete
    Replies
    1. We have been spoiled by Delphi compilers and Windows SEH. Other platforms and languages work on different premises. That alone is not such a problem as changing the coding practice.

      I also updated the post with link to underlying LLVM issue, because the issue exists in LLVM and it is not Delphi specific.

      Delete
  2. Once again you have underscored why NOT to use LLVM backed compilers. It is hard to believe that a flaw this fundamental has STILL not yet be been addressed either by the LLVM compiler or Delphi's automatic management of a known flaw in the compiler.

    I don't consider myself to be "spoiled" by the delphi compiler doing what it claims to do, I feel betrayed by the choice to force flawed technology upon developers.

    ReplyDelete
    Replies
    1. If the issue in LLVM could be easily fixed, that would have been done already.

      The technology is not flawed per-se. LLVM originated from supporting languages with slightly different requirements, and it also works fine for Delphi, but it also requires slight change in coding practice in some areas.

      If LLVM is so bad, then giants like Apple and Google wouldn't be using it.

      It is not the "flaw" in the technology that is as much of a problem, but the required changes for the existing code. And LLVM was and still is the best solutions for compiler backend that allowed Embarcadero to extend Delphi across different platforms.

      Delete

Post a Comment

Popular posts from this blog

Coming in Delphi 12: Disabled Floating-Point Exceptions

Assigning result to a function from asynchronous code

Beware of loops and tasks