Patterns are a familiar tool of software development for the past 20+ years. You'll have heard of Singleton, Bridge, Decorator and a couple of dozen more.
They cover a lot of ground and you can do years of software development using them as daily tools and probably never even use all of them.
However there are some everyday tasks for which they just don't seem to be directly all that helpful. Error handling is of those. Logging is another.
I will show a pattern and the outline of its implementation that make these tasks easier and more consistent, although they are only one application of the general Flyer pattern.
First however the motivating problem. Half a century of trying to do error handling without breaking everything.
Let's say you write a function that calls an API. It could be something like a file read or maybe this Windows network API call.
SOCKET WS2::accept( SOCKET s, sockaddr* addr, int* addrlen )
{
static const Library::DefProc pFunc =
reinterpret_cast<Library::DefProc>(
Kernel32::GetProcAddress( reinterpret_cast< ::HMODULE >(
Kernel32::GetModuleHandleW(L"ws2_32") ), ( "accept" ) ) );
SOCKET Result = Library::Call<
SOCKET, SOCKET, sockaddr*, int* >(
pFunc, s, addr, addrlen );
return Result;
}
The neat mathematical concept of a function as a mapping form its inputs to its outputs (in this case from a socket and an address to another socket) is ruined by the fact that this call can fail. What's worse is that is it can fail in a number of very different ways but we'll get to that.
What are our available strategies for handling failure:
Sentinel values
This one goes back at least 50 years and is pretty simple. We could reserve a value for the returned SOCKET which indicates that something went wrong.
For example -1. This is indeed what the socket API does, Windows copying BSD going all the way back into the dark ages.
The downsides of this are:
We're relying on the caller to actually check the return and to know that -1 means accept failed.
We have no way of indicating why it failed. For that the caller will need to call back into the API and there will need to be an error state held somewhere, which we'd better hope is per-thread and hasn't been reset by an intermediate call.
The upside is we can pretend that is still a mathematical style function which neatly maps inputs to outputs.
There are cases for sentinel values but their historic use for error handling causes problems as described above. All in all it's a poor solution that should probably stay in the mid 20th century where it originated.
Out parameters
We could change the signature of accept to take an extra parameter by reference.
SOCKET WS2::accept(
SOCKET s, sockaddr* addr, int* addrlen,
WS2ErrorState& errState);
This solves the problems of how we inform the caller of why the call failed and of having to store and retrieve error state with a second call.
However it introduces new problems. We have to have an extra parameter on every call in our network API. Everything has to know what a WS2ErrorState is and now our caller has to manage the error state, making sure it's initialised before they pass it in. We've moved the problems around but haven't really solved them and everything is more complex and verbose as a result.
The 1980s can probably keep this solution. We'll move on.
Exceptions
By the 1980s the problems with Sentinel values and extra reference parameters for error handling were well understood and C++ brought out the big tools to solve the problem once and for all.
I'm not going to deep dive into how exceptions work and what the issues with them are here but briefly, they require explicit support in the language and the compiler and in some cases the OS as well. They're powerful, dangerous, maybe expensive, tricky to think about and completely unavailable in some embedded environments.
They're also somewhat unavoidable and deeply embedded in some OS APIs. For example our sample code itself calls Library::Call which looks like this:
template< typename ret, class ...MethodArgs >
static ret Call(const DefProc pProc, MethodArgs... args)
{
typedef ret(*fPtr)(MethodArgs...);
fPtr FP = reinterpret_cast<fPtr>(pProc);
if (FP == nullptr)
{
throw("Missing library function.");
}
__try
{
return (FP)(
std::forward< MethodArgs >(args)...);
}
__except(ExceptionFilterFunction(
GetExceptionInformation()))
{
}
ret _{};
return _;
}
This uses two slightly different type of exception handling. One of which (structured exception handling) is Windows specific. We'll see later how that gets turned back into a platform neutral error.
Exceptions will always be with us, although many people dislike and avoid them whenever they can but surely modern C++ has some better answers.
It does and they all come in the form of a different approach to how to return error information.
Structured bindings
C++17 allows us to return a tuple< SOCKET, WS2ErrorStatus > and for the caller to assign the two elements of the tuple directly to separate variables without having to call std::get< >
tuple< SOCKET, WS2ErrorStatus > WS2::accept(
SOCKET s, sockaddr* addr, int* addrlen );
auto [ socket, error ] = accept( s, addr, addrlen);
This is great, however it still means we have to change the signatures of all our functions. We're still relying on the caller to actually check the error and not ignore it. At least we can generate the WS2ErrorStatus inside our accept function and error state is all nicely on the stack.
For a brief moment sometime in 2018 everyone thought this was the answer but it's still unsatisfyingly awkward and messy at scale.
std::optional
The modern standard library provides many ways to skin cats. Instead of structured binding we could return a std::optional< SOCKET >
This provides the caller a way to determine if we really returned a SOCKET or just an empty result. Essentially we're back to the sentinel but with all the sophistication of type safety. Now we don't have to reserve a special value within the type of SOCKET to indicate failure, we get a smart API on std::optional< SOCKET > to tell us it's empty.
However all the other problems of sentinels, like not being able to tell what went wrong without calling back to the API and the state management consequences of that are back. Plus ça change.
std::expected
"std::expected is a powerful feature introduced in C++23 that offers a modern, type-safe alternative to traditional error-handling methods."
Instead of wrapping up our SOCKET return value with a Boolean to say if it's empty or not std::expected wraps it up with a type of our choosing allowing us to return the SOCKET and an error.
std::expected<SOCKET, WS2ErrorStatus> WS2::accept(
SOCKET s, sockaddr* addr, int* addrlen );
std::expected<SOCKET, WS2ErrorStatus> result =
accept(s, addr, addrlen);
if(result.has_value())
{
//happy path
use(result.value());
}
else
{
report(result.error());
}
This does, technically, solve a lot of the problems and as we know that's the best kind of solution. However if you squint a bit you'll notice that this is incredibly similar to the structured bindings solution which fell out of favour pretty quickly, even thought it also, technically, works.
That's it then. We've exhausted the cat skinning options of the standard library and still the only way to get useful error information out of our accept function, without changing the signature or shuffling runtime responsibilities onto our caller, is to use exceptions. Sigh.
The Flyers are coming!
If I were doing that deep dive into exceptions which I'm not. One of the things we'd encounter is that way down at the base of the stack for every thread, on Windows at least, there is an exception control block which holds data required for exception handling on that thread. In other words exception support injects extra per-thread state that the runtime uses to do its dark magic. Can we do the same?
In short, yes. We can set up some per-thread state which maps a type to a pointer. I'll link to the full implementation at the end but this is outline of the FlyerMap
class FlyerMap final
{
public:
inline FlyerMap() = default;
FlyerMap(const FlyerMap&) = delete;
inline ~FlyerMap(){ m_Map.clear(); }
inline AnyObject Configure(
const GUID* classID, AnyObject context){/*...*/}
inline void Unconfigure(
const GUID* classID, AnyObject context){/*...*/}
inline AnyObject Lookup(
const GUID* classID){/*...*/}
private:
std::map< GUID, AnyObject > m_Map;
};
The Configure and Unconfigure functions allow us to set which instance of a particular type is the current flyer for that type. They're almost identical except Configure returns the previous instance in a type erased AnyObject.
The Flyer map gives us a place to 'remember' the current Flyer now we need something to remember.
template< class T, class baseT >
class Flyer : public baseT
{
public:
Flyer() : baseT() { }
virtual ~Flyer() = default;
Flyer(const Flyer& src) = delete;
Flyer& operator = (
const Flyer& src) = delete;
bool Push()
{
typename ref_of< T >::type
instance( dynamic_cast<T*>(this) );
const GUID* luid = guid_of<T>::guid();
TypedAny< T > wrapper(instance);
m_anyPrevious =
CurrentThread::GetCurrent().Context().
GetFlyerMap().Configure( luid, wrapper);
if(!m_anyPrevious.IsNull())
{
m_Previous =
reinterpret_cast<const TypedAnyPointer< baseT >*>(
m_anyPrevious.Ptr())->operator baseT *();
}
return true;
}
bool Pop()
{
CurrentThread::GetCurrent().Context().
GetFlyerMap().Unconfigure(
guid_of<T>::guid(), m_anyPrevious);
return true;
}
protected:
typedef baseT base_type;
AnyObject m_anyPrevious;
baseT* m_Previous{nullptr};
};
This gives us a base template for Flyers. The important details are the Push and Pop functions which interact with the Flyer map.
Push sets a Flyer into the map, while holding onto the previous item that was replaced.
Pop removes a Flyer from the map and puts back the previous item.
These functions are called from Flyer constructors and destructors.
template<class T>
class IssueHandler :
public Flyer< IssueHandler<T>, BaseIssueHandler >
{
public:
IssueHandler()
{
Flyer< IssueHandler<T>, BaseIssueHandler >::Push();
}
virtual ~IssueHandler()
{
Flyer< IssueHandler<T>, BaseIssueHandler >::Pop();
}
virtual bool Handle(const T& /*Issue*/)
{
return false;//false here means failure to resovle the issue
}
};
The CRTP template trickery is only necessary so that the ultimate base class can have a concrete type that we can attach ID to for differentiating Flyer types. BaseIssueHandler is essentially an empty type.
IssueHandler serves as a base for a broad category of Flyers, more than just errors but here's the entire specialization for error handling.
class DefaultErrorHandler :
public IssueHandler<ErrorBase>
{
public:
virtual bool Handle(const ErrorBase& error);
};
To handle Windows specific errors we use a Win32ErrorHandler specialization with an identical signature.
Using an error handler is as simple as putting one on the stack.
SOCKET WS2::accept(
SOCKET s, sockaddr* addr, int* addrlen )
{
Win32ErrorHandler _;
...
but how, you may ask, do we turn API specific sentinel values and OS specific exceptions into errors that can be handled by our error handler?
The answer (as Jason Turner will always remind you) is stronger types. But before we get there lets reroute the structured exceptions we caught in Library::Call as that's actually quite straight forward.
static int ExceptionFilterFunction(
LPEXCEPTION_POINTERS exception_pointers)
{
serious(
"Win32 API Exception: Code {0}, Address {1}, Flags {2}, Info {3}, {4} parameters.",
exception_pointers->ExceptionRecord->ExceptionCode,
exception_pointers->ExceptionRecord->ExceptionAddress,
exception_pointers->ExceptionRecord->ExceptionFlags,
exception_pointers->ExceptionRecord->ExceptionInformation[0],
exception_pointers->ExceptionRecord->NumberParameters
);
return EXCEPTION_EXECUTE_HANDLER;
}
Remember we passed ExceptionFilterFunction as our except filter in the __except clause in Library::Call
__except(ExceptionFilterFunction(
GetExceptionInformation()))
So if we do get an exception we'll extract all the exception details and pass them along with a formattable string to the serious function.
template< typename... _p >
void serious(const std::string& message, _p&&... p1)
{
issue<Serious, const std::string&>(
std::vformat(
std::string_view(message), std::make_format_args(p1...)));
}
This just formats the parameters into the string and calls the issue template function with the Serious class. That in turn will construct an instance of Serious for us and calls Handle() on it.
class Serious : public ErrorBase
{
public:
Serious(const std::string& message);
Serious(const Serious& src);
virtual ~Serious() noexcept;
Serious& operator = (const Serious& src);
virtual void Handle();
virtual void Escalate() const;
virtual void Ignore() const;
};
Serious is a specialization of ErrorBase for errors in the serious category. In this implementation we categorize errors as Warning, Continuable, Serious or Fatal. With Serious errors being ones where the thread of execution can't continue as normal. It will need to clean up and close down.
void Serious::Handle()
{
auto pSeriousHandler =
new_ref< IssueHandler<Serious> >();
if(!pSeriousHandler.IsNull())
{
Resolve(pSeriousHandler->Handle(*this));
}
else
{
auto handler = new_ref< IssueHandler<ErrorBase> >();
Resolve(handler.IsNotNull() ? handler->Handle(*this) : false);
}
}
At first glance this looks as if Serious::Handle is creating a new IssueHandler< Serious > to hand off to but under the hood new_ref is actually looking up the Flyer Map for the current thread and returning a pointer to the Win32ErrorHandler we put on the stack earlier.
Here's part of the factory that does that lookup.
template< class T >
struct factoryFunctor< T, FlyerRef< T > >
{
static void Destruct(size_t /*count*/, T* t);//...
static FlyerRef< T > Construct(size_t)
{
AnyObject context =
CurrentThread::GetCurrent().Context().
GetFlyerMap().Lookup(guid_of<T>::guid());
if (!context.IsNull())
{
FlyerRef< T > pCurrent(
context.operator T*());
return pCurrent;
}
return FlyerRef< T >();
}
};
Once we have the address of the Win32ErrorHandler that get's called.
bool Win32ErrorHandler::Handle(
const qor::ErrorBase& error)
{
switch(error.what().GetSeverity())
{
//...
case Severity::Serious_Error:
{
std::string message =
GetLastErrorAsString();
::OutputDebugStringA(
std::format("{0}. Windows error status - {1}",
error.what().Content(), message).c_str());
return false;
}
break;
//...
This now handles the otherwise annoying call back into the underlying API to get the details of the last error and outputs details through the kernel to any attached debugger.
The return false; is significant here. That means we didn't resolve the error, we just reported it. This result will get passed to the Resolve function on the specific Error type. In this case Serious which will Escalate the error because it wasn't successfully handled. For Serious errors that defaults to throwing an exception but this is highly implementation specific.
void Serious::Escalate() const
{
throw(*this);
}
We could choose to fallback to a Flyer based retry handler or any other suitable behaviour.
Now for the that stronger type to catch non-exception errors from the Windows API. In other words to turn a sentinel value into an Error we can pass to a Flyer based handler.
For this we use a CheckReturn template.
template<class TReturn, class TCheck >
class CheckReturn
{
public:
typedef CheckReturn< TReturn, TCheck > TType;
CheckReturn() : m_bInitialised(false)
{
TCheck AutoCheck;
TCheck::Init(m_Param, &AutoCheck);
}
CheckReturn(TReturn param) : m_Param(param), m_bInitialised(true)
{
TCheck AutoCheck;
TCheck::Check(m_Param, &AutoCheck);
}
CheckReturn& operator = (TReturn& value)
{
m_Param = value;
m_bInitialised = true;
TCheck AutoCheck;
TCheck::Check(m_Param, &AutoCheck);
return *this;
}
operator TReturn()
{
if (!m_bInitialised)
{
continuable("Uninitialised parameter error");
}
return m_Param;
}
protected:
TReturn m_Param; //Instance of parameter type
private:
bool m_bInitialised;
};
This encapsulates the type we're returning and a check type. The check is operated when we assign to the CheckReturn from an instance of our return type and the CheckReturn auto converts to the return type on request (with an uninitialized check) so that when we return it we don't have to change our function return type.
Note: This only works for return by value. That's what almost all API calls do, so this isn't a limitation in practice but CheckReturn is not the kind of universal solution you'd find in the standard library. It's a situation specific utility class.
We need to plug in a check class to actually check our SOCKET return value.
template< typename TFailure, TFailure iFailure >
class TCheckWinsockFailureValue
{
public:
virtual void DefaultInit(TFailure& value)
{
value = iFailure;
}
virtual bool Test(TFailure& value)
{
return (value == iFailure) ? false : true;
}
static void Check(TFailure& value, TCheckWinsockFailureValue* pInstance)
{
if (!pInstance->Test(value))
{
WSALastErrorHandler();
}
}
static void Init(TFailure& value, TCheckWinsockFailureValue* pInstance)
{
pInstance->DefaultInit(value);
}
};
WSALastErrorHandler() is another function to call back into the Windows API to get the last WSA specific error and, this time, raise it as a continuable error.
void WSALastErrorHandler()
{
int wsaLastError = WS2::WSAGetLastError();
//...message formatting
continuable(errorDescription);
}
continuable is a free function, just like serious, that creates a specialization of an issue and calls Handle() on it.
Our accept function return type then becomes:
CheckReturn< SOCKET,
TCheckWinsockFailureValue< SOCKET, INVALID_SOCKET>
>::TType Result;
A self checking wrapper which checks against the INVALID_SOCKET sentinel value when it is assigned with a SOCKET value and decays to that socket value when it's returned. (Note: this probably does prevent RVO but it's not significant for the vast majority of Windows API calls.)
There's one more case we haven't covered. Back in the Library::Call function if the function endpoint isn't present we throw.
template< typename ret, class ...MethodArgs >
static ret Call(const DefProc pProc, MethodArgs... args)
{
typedef ret(*fPtr)(MethodArgs...);
fPtr FP = reinterpret_cast<fPtr>(pProc);
if (FP == nullptr)
{
throw("Missing library function.");
}
...
In this case we throw a const char*, not a std::exception or anything derived from ErrorBase precisely because we don't want this to be caught, except by a global fallback handler. We could just call std::terminate, which is what will likely happen anyway but as this is a library we allow the containing executable to do that along with any reporting it wants to do first.
Not having the required library loaded (The overwhelmingly likely cause) is fatal and we don't want program execution to continue in any case.
In a scenario where exceptions weren't supported we could swap this out with a call to fatal("Missing library function"); for a similar effect via the Flyer based system.
The final WS2::accept function, fully expanded looks like this:
SOCKET WS2::accept( SOCKET s, sockaddr* addr, int* addrlen )
{
qor::FunctionContext _FContext_(
__FUNCSIG__, __FILE_, __LINE__, ThisModule().Name(), false, false);
Win32ErrorHandler _;
CheckReturn< SOCKET,
TCheckWinsockFailureValue< SOCKET, INVALID_SOCKET>
>::TType Result;
static const Library::DefProc pFunc =
reinterpret_cast< Library::DefProc>(
Kernel32::GetProcAddress(
reinterpret_cast< ::HMODULE >(
Kernel32::GetModuleHandleW(
(L"ws2_32") ) ), "accept") );
Result = Library::Call<
SOCKET, SOCKET, sockaddr*, int* >(
pFunc, s, addr, addrlen );
return Result;
}
Which reduces to the much more readable:
SOCKET WS2::accept( SOCKET s, sockaddr* addr, int* addrlen )
{
qor_pp_fcontext;
Win32ErrorHandler _;
CheckReturn< SOCKET,
TCheckWinsockFailureValue< SOCKET, INVALID_SOCKET>
>::TType Result;
qor_pp_useswinapi(ws2_32, accept );
Result = Library::Call<
SOCKET, SOCKET, sockaddr*, int* >(
pFunc, s, addr, addrlen );
return Result;
}
with the macros put back in.
Conclusion:
We've taken an error handling worst case scenario where we have OS specific exception handling and old fashioned sentinel values that require re-calling into the API to get error details.
We've contained both the exception case and the soft error cases within the existing function without changing the signature, while routing them differently to custom handlers. We've done this in the presence of an existing fatal exception flow which we've left untouched. We get to choose whether our unhandled errors turn into exceptions or not and we can categorize errors by as many severity levels as we need, treating each differently, or falling back to default handling.
The required Flyer infrastructure is non trivial. This is a solution for a larger scale project. Once it's in place though, it is trivial to use.
Win32ErrorHandler handler; being enough to create an error handler with automatic lifetime management and the warning, continuable, serious and fatal free functions being the entire rest of the error system API.
Flyer based error handling and logging is a QOR technology. Development is ongoing at https://github.com/mfaithfull/linuxQOR where you can get the complete reusable Flyer system as a small collection of cross platform libraries for Linux and Window, or the entire application development framework.
Top comments (0)