Hi all, today we’re gonna work on properly handling errors in our managed code. Right now, if we run into an exception in our managed code, our application will crash as we are not catching the exceptions. This is especially important as your scripting engine will run user-created scripts. Ideally, we wouldn’t want script errors to cause our whole application to crash. In this post, we’ll look at how to catch these exceptions and handle them gracefully.

This is the sixth in a series of posts on building a .NET 6.0-based C# scripting engine for a game engine. If you’ve not yet read the previous posts, you can start here.

Types of Exceptions

Before we begin writing some code, we need to understand what sort of exceptions we are catching.

C++ Exceptions

C++ exceptions are normal exceptions that you should be used to when writing normal C++ code. Examples of these include:

  • std::invalid_argument thrown when calling std::stoi with text that has no integers
  • std::out_of_range thrown when accessing an invalid index of a std::vector
  • std::bad_alloc thrown when a call to new fails

All of these exceptions inherit from the std::exception base class which contains a what() member function that gives additional information about the exception. This is an important property which we will use later.

Managed Exceptions

Managed exceptions on the other hand, are exceptions thrown by managed code (C++/CLI, C#, etc.). For example:

  • System.NullReferenceException thrown when accessing a null object
  • System.DivideByZeroException thrown when dividing an integer by 0
  • System.ArgumentOutOfRangeException thrown when accessing an invalid index of a System.String

All of these exceptions inherit from the System.Exception base class which contains a Message property that gives additional information about the exception. Much like with the C++ exceptions described above, we will be using this later.

Structured Exception Handler (SEH) Exceptions

SEH exceptions only occur in C/C++ code on Windows (on Linux, these are signals). These are thrown when critical errors occur, usually CPU exceptions, such as read access violations or stack overflows. These were originally written by Microsoft to handle exceptional circumstances in C code which does not support any form of exceptions in the specifications.

Unlike the previous two exceptions, all SEH exceptions use the same object to describe them.

Handling Exceptions

Handling these exceptions are pretty simple, we will just use a simple try and catch block for our script update code but we will need to catch the correct exceptions.

C++ Exceptions

For C++ exceptions, all STL exceptions (and it is common good practice for 3rd party classes) inherit from the base std::exception class. However, in C++, it is possible to throw any variable as an exception. To handle both of these cases, we will implement it like this:

// EngineInterface.cxx
#include <iostream>
...
namespace ScriptAPI
{
    ...
    void EngineInterface::ExecuteUpdate()
    {
        for each (ScriptList^ entityScriptList in scripts)
        {
            // Update each script
            for each (Script^ script in entityScriptList)
            {
                try
                {
                    script->Update();
                }
                catch (const std::exception& e)
                {
                    std::cout << "Native Exception: " << e.what() 
                              << std::endl;
                }
                catch (...)
                {
                    System::Console::WriteLine("Unknown exception caught.");
                }
            }
        }
    }
    ...
}

Notice how we used std::cout to print out the native exception’s error message instead of using System::Console::WriteLine()? The reason for this is that if we were to pass native strings over to managed functions, we would need to do some marshaling. To keep things simple, we will avoid that for now.

Managed Exceptions

As for managed exceptions, we’ll add on to the code that we had previously and simply catch the base class.

// EngineInterface.cxx
...
namespace ScriptAPI
{
    ...
    void EngineInterface::ExecuteUpdate()
    {
        ...
        try
        {
            script->Update();
        }
        catch (const std::exception& e)
        {
            std::cout << "Native Exception: " << e.what() 
                      << std::endl;
        }
        catch (System::Exception^ e)
        {
            System::Console::WriteLine("Managed Exception: " + e->Message);
        }
        catch (...)
        {
            System::Console::WriteLine("Unknown exception caught.");
        }
        ...
    }
    ...
}

Structured Exception Handler (SEH) Exceptions

There is nothing else to do, by default the managed exception handler automatically handles these exceptions by seemingly mapping them to the closest .NET exception type.

Testing

Preparation

For us to test native and SEH exceptions, we need to provide some code that may cause that. To make this easy, we’ll add these 2 functions to our Script class.

// Script.hxx
namespace ScriptAPI
{
    public ref class Script abstract
    {
      public:
        ...
        void CreateNativeException();
        void CreateSEHException();
        ...
    };
}
// Script.cxx
namespace ScriptAPI
{
    ...
    void Script::CreateNativeException()
    {
        throw std::runtime_error("Intentional Error!");
    }
    void Script::CreateSEHException()
    {
        int* i = nullptr;
        *i = 42;
    }
    ...
}

Trying It Out

Now let’s give it a try, we’ll modify our TestScript.cs to test out all of the exceptions. As you should know, when an exception is thrown, it skips any code after it. Therefore, to test this properly, you will need to comment out each set of exceptions in the following code and run it one by one to verify that all exceptions are handled without crashing the application.

using ScriptAPI;

public class TestScript : Script
{
    public override void Update()
    {
        // Creates a native exception
        CreateNativeException();
        // Creates a manage exception
        List<int> nullList = null;
        nullList.Add(1);
        // Creates a SEH Exception
        CreateSEHException();
    }
}

Null Reference Exceptions

Even with our exception handlers put into place, our code is still causing a break when running with debugging from Visual Studio. The reason for this is that by default, Visual Studio will always break when it encounters a NullReferenceException even if the exception is caught, however, if you click “Continue” in Visual Studio, you’ll see that the application resumes with no issues.

To fix this, we can simply check the “Break when this exception type is thrown” checkbox when it happens and you should not see this issue ever again. Do note that when a NullReferenceException occurs that is uncaught, Visual Studio will still break regardless so there is no cause for concern.

One thing to note is that since by default, Visual Studio will not run the managed debugger unless explicitly enabled, you will see an “Access violation writing location” exception instead of a managed exception. This is normal and is expected behaviour.

Code Reuse

Alright, so we have successfully protected our script update loop from causing an application crash. However, in a real application, there is more than a single script function. Furthermore, it would be best to reinforce the functions that interface with our C++ code with the same protection. Having to add these try and catch blocks all over the code can be extremely tedious and what if you would like to change the formatting of these messages in the future?

The simplest solution? We wrap the try and catch blocks in a pair of macros.

We chose macros as other methods such as using function templates would result in a lot of boilerplate. One possible work around to this would to use lambdas, however, lambdas are not supported in managed code in C++/CLI. So we’ll create a new file called Debug.hxx and define the macros there.

// Debug.hxx
#pragma once
#include <stdexcept>
#include <iostream>

#define SAFE_NATIVE_CALL_BEGIN try {
#define SAFE_NATIVE_CALL_END                                       \
}                                                                  \
catch (const std::exception& e)                                    \
{                                                                  \
    std::cout << "Native Exception: " << e.what() << std::endl;    \
}                                                                  \
catch (System::Exception^ e)                                       \
{                                                                  \
    System::Console::WriteLine("Managed Exception: " + e->Message);\
}                                                                  \
catch (...)                                                        \
{                                                                  \
    System::Console::WriteLine("Unknown exception caught.");       \
}

We will then amend our script update loop to use these macros:

// EngineInterface.cxx
#include "Debug.hxx"
...
namespace ScriptAPI
{
    ...
    void EngineInterface::ExecuteUpdate()
    {
        for each (ScriptList^ entityScriptList in scripts)
        {
            // Update each script
            for each (Script^ script in entityScriptList)
            {
                SAFE_NATIVE_CALL_BEGIN
                script->Update();
                SAFE_NATIVE_CALL_END
            }
        }
    }
    ...
}

Lastly, we should also add these macros to our EngineInterface::AddScriptByName() function as that would be called by our core engine code and it shouldn’t cause a crash if it fails. Instead, if it fails, we should handle it properly and return false.

// EngineInterface.cxx
...
namespace ScriptAPI
{
    ...
    bool EngineInterface::AddScriptViaName(int entityId, System::String^ scriptName)
    {
        SAFE_NATIVE_CALL_BEGIN
        ...
        SAFE_NATIVE_CALL_END
        return false;
    }
    ...
}

Breakpoint Debugging

Great! We have our code properly protected from unexpected exceptions and handled them gracefully. That said, if it so happens that our actual scripting engine code runs into an exception, how do we debug that? One of the most common ways of debugging is to use breakpoints. However, by default, we don’t really have the ability to do that with our C++/CLI code since Visual Studio only attaches the native C++ debugger.

To solve this, we can enable the mixed mode debugger by opening up our start up project’s properties and set the debugger type to “Mixed (.NET Core)”.

Do take note that these settings are saved in the solution’s user settings. Hence, you will need to ensure that this is enabled on every device that you are developing on.

Conclusion

That’s it for this part of the tutorial. It isn’t terribly exciting compared to the previous parts but it still a very important part of the engine that would allow it to become useful to your users. Next up, we’ll work on implementing hot reloading so that we don’t have to recompile our entire application when making changes to our C# scripts.

The repository of a working solution based on this tutorial can be found here.

Categories: Tutorials

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.