1 Commits

Author SHA1 Message Date
5aeea26645 Improved README 2025-12-29 22:29:28 -05:00
2 changed files with 239 additions and 227 deletions

View File

@@ -14,7 +14,7 @@ pkg_check_modules(SDL3 REQUIRED sdl3)
# Add include directories
include_directories(${SDL3_INCLUDE_DIRS})
add_library(sdlerror STATIC
add_library(sdlerror SHARED
src/error.c
)

462
README.md
View File

@@ -1,279 +1,291 @@
# Summary
# libsdlerror
This library provides a TRY/CATCH style exception handling mechanism for C.
**Explicit, disciplined error handling for C — built on SDL3.**
# Dependencies
`libsdlerror` is a small library that helps C programmers write correct, readable, and maintainable error-handling code without pretending that C is something it is not.
This library depends on the `SDL3` library and `stdlib`. Specifically it uses the SDL_Log method from SDL3.
It does not add exceptions to C.
It does not hide control flow.
It does not allocate memory at runtime.
# Installation
If you want magic, look elsewhere.
```bash
---
## Why This Library Exists
Error handling in C is not hard — but it is tedious, repetitive, and easy to get wrong.
The usual pattern:
```c
if (foo() < 0) {
fprintf(stderr, "%s\n", SDL_GetError());
cleanup();
return -1;
}
```
works, until it doesnt. As programs grow:
- Error checks get duplicated
- Cleanup paths multiply
- Context disappears
- Control flow becomes fragmented
SDL compounds this by storing errors in a single thread-local string, forcing programmers to either handle errors immediately or lose information.
`libsdlerror` exists to impose structure and discipline on this process — without compromising the explicitness that makes C reliable.
---
## What This Library Is — and Is Not
This library is deliberately conservative.
### 🚫 No `setjmp`, No `longjmp`
Many C error libraries attempt to simulate exceptions using `setjmp` / `longjmp`.
That approach is rejected here.
Non-local jumps:
- Bypass normal control flow
- Skip cleanup invisibly
- Break assumptions about stack lifetime
- Make reasoning about correctness harder, not easier
If control flow moves in `libsdlerror`, it does so because the source code says so.
Nothing jumps. Nothing unwinds itself. Nothing “just happens.”
---
### 🚫 No Runtime Memory Allocation — Ever
`libsdlerror` never performs dynamic memory allocation at runtime.
It does not call:
- `malloc`
- `calloc`
- `realloc`
- `free`
There is no allocator dependency and no runtime allocation failure mode.
---
### 📌 Explicit Storage Model
All memory used by `libsdlerror` falls into one of two categories:
- **Automatic storage (stack)**
Caller-owned error contexts and local state
- **Static storage duration**
Fixed-size internal tables allocated at program load time
There is no heap allocation and no runtime resizing.
---
### ✅ C Means C
- No compiler extensions
- No undefined behavior
- No optimizer tricks
If it compiles as C, it works as C.
---
### ✅ Designed for SDL, Not Against It
SDL already has an error system. It is simple, global, and fragile.
`libsdlerror` does not replace it. It captures SDL error state at the right moment, preserves context, and makes it usable without forcing error checks everywhere.
---
## The Six Guiding Principles
This library is opinionated, and intentionally so.
1. **Control flow must be explicit**
If execution moves, the source code must show why.
2. **No `setjmp` / `longjmp`**
Errors must not bypass the stack.
3. **No runtime memory allocation**
The library relies only on stack and static storage.
4. **The caller owns all state**
No hidden globals exposed to the user.
5. **Cleanup must be deterministic**
Cleanup always runs, exactly once.
6. **Errors are values, not side effects**
They are captured, inspected, propagated, or handled deliberately.
---
## Quick Start
### Build and Install
```sh
cmake -S . -B build
cmake --build build
cmake --install build
```
# Philosophy of Use
### Compile Your Program
This library has 6 guiding principles:
```sh
gcc `pkg-config --cflags sdlerror` main.c \
`pkg-config --libs sdlerror` \
-o app
```
* Manually checking every possible return code for every possible meaning of that return code is tedious and prone to miss unpredicted failure cases
* Functions should return rich descriptive error contexts, not values
* Uncaught errors should cause program termination with a stacktrace
* Dynamic memory allocation is the source of many errors and should be avoided if possible
* Manipulating the call stack directly is error prone and dangerous
* Declaring, capturing, and reacting to errors should be intuitive and no more difficult than managing return codes
# Using the library
## Simply
Include it
### Minimal Example
```c
#include <sdlerror.h>
```
Link against it
int main(void) {
PREPARE_ERROR(err);
```sh
cc -lsdlerror
```
.. Done.
## CMake dependencies
Using pkg-config:
```sh
pkg-config sdlerror --cflags
pkg-config sdlerror --ldflags
```
Using cmake:
```cmake
find_package(sdlerror REQUIRED)
pkg_check_modules(sdlerror REQUIRED sdlerror)
target_link_libraries(YOUR_TARGET PRIVATE sdlerror::sdlerror)
```
# Functions and Return Codes
This library can perform tests on any function or expression that returns an integer value.
Any function which uses the `PREPARE_ERROR` macro should have a return type of `ErrorContext *`. The macros within this library, when they detect an unhandled error, will attempt to pass up the unhandled error to the context of the previous function in the call stack. This allows for errors to propagate up through the call stack in the same way as exceptions. (For example, if you use traditional C error handling in a call stack of `a() -> b() -> c()`, and `c()` fails because it runs out of memory, `b()` will likely detect that error and return some error to `a()`, but it may or may not return the context of what failed and why. With this, you get that context all the way up in `a()` without knowing anything about `c()`.
# Error codes
The library uses integer values to specify error codes inside of its context. These integer return codes are defined in `sdlerror.h` in the form of `ERR_xxxxx` where `xxxxx` is the name of the error code in question. See `sdlerror.h` for a list of defined errors and their descriptions.
You can define additional error types by defining additional `ERR_xxxxx` values. Begin your error values at 128. Define a human-friendly name for the error with the `error_name_for_status` method:
```c
error_name_for_status(129, "Some Error Code Description")
```
When you add additional error codes, you need to define `-DMAX_ERR_VALUE=n` where `n` is the maximum error code you have defined.
# Setting up the error context
Before you can use any of these macros you must set up an error context inside of the current scope.
```c
PREPARE_ERROR(errctx);
```
This will create a ErrorContext structure inside of the current scope named `errctx` and initialize it. This structure is used for all operations of the library within the current scope. Attempting to use the library in a given scope before calling this will result in compile-time errors.
#
# Attempting an operation
```c
ATTEMPT {
// ... code
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true)
```
CATCH(err, SDL_Init(SDL_INIT_VIDEO));
CATCH(err, do_something_that_can_fail());
}
CLEANUP {
SDL_Quit();
}
PROCESS(err) {
fprintf(stderr, "Error: %s\n", ERROR_MESSAGE(err));
return 1;
}
FINISH(err, true);
`ATTEMPT { ... }` is the block within which you will perform operations which may cause errors that need to be caught. See "Capturing errors", below.
`CLEANUP { ... }` is the block within which you will perform any code which MUST be executed REGARDLESS of whether or not errors were thrown. Closing open file handles, or releasing memory, for example.
`PROCESS(errctx) { ... }` is the block within which you will handle any errors that were caught inside of the `ATTEMPT` block. See "Handling Errors" below.
`FINISH(errctx, true)` terminates the attempt operation. The `FINISH` macro takes two arguments: the name of the ErrorContext, and a boolean regarding whether or not to pass unhandled errors up to the calling function. Unless you have a good reason not to, this should be true.
# Capturing errors
Inside of an `ATTEMPT` block, any operation which could generate or represent an error should be wrapped in one of several macros.
## Capturing errors from functions which return ErrorContext *
For functions that return `ErrorContext *`, you should use the `CATCH` macro.
```c
ATTEMPT {
CATCH(errctx, errorGeneratingFunction())
} // ...
```
This will call assign the return value of the function in question to the ErrorContext previously prepared in the current scope. If the function returns an ErrorContext that indicates any type of error, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins.
## Setting errors from functions or expressions returning integer
For functions that return integer, such as logical comparisons or most standard library functions, use the `FAIL_ZERO_BREAK` and `FAIL_NONZERO_BREAK` macros. These macros allow you to capture an integer return code from an expression or function and set an error code in the current context based off that return.
Here is an example of checking for a NULL pointer
```c
ATTEMPT {
FAIL_ZERO_BREAK(errctx, (somePointer == NULL), ERR_NULLPOINTER, "Someone gave me a NULL pointer")
} // ...
```
Here is an example of checking for two strings that are not equal
```c
ATTEMPT {
FAIL_NONZERO_BREAK(errctx, strcmp("not", "equal"), ERR_VALUE, "Strings are not equal")
} // ...
```
When either of these two macros are used, the `ATTEMPT` block is immediately exited, and the `CLEANUP` block begins.
# Handling errors
Inside of the `PROCESS { ... }` block, you must handle any errors that occurred during the `ATTEMPT { ... }` block. You do this with `HANDLE`, `HANDLE_GROUP`, and `HANDLE_DEFAULT`.
## Handling a specific error with HANDLE
In order to handle a specific error code, use the `HANDLE` macro.
```c
} PROCESS(errctx) {
} HANDLE(errctx, ERR_NULLPOINTER) {
// Something is complaining about a null pointer error. Do something about it.
} // ...
```
## Handling a group of errors with HANDLE_GROUP
In order to handle a group of related errors that all require the same failure behavior, use `HANDLE` followed by `HANDLE_GROUP`. For example, to handle a scenario where an IO error, key error, and index error all need to be handled the same way:
```c
} PROCESS(errctx) {
} HANDLE(errctx, ERR_IO) {
} HANDLE_GROUP(errctx, ERR_KEY) {
} HANDLE_GROUP(errctx, ERR_INDEX) {
// error handling code goes here
return 0;
}
```
This creates a fallthrough mechanism where all 3 errors get the same error handling code. Note that while the cases fall through, you can still (if desired) put some code specific to each error in that error's `HANDLE` or `HANDLE_GROUP` block; but this is not required, only the final handler needs to get any code.
---
The fallthrough behavior stops as soon as another `HANDLE` macro is encountered. For example, in this example, `ERR_IO`, `ERR_KEY` and `ERR_INDEX` are all handled as a group, but `ERR_RELATIONSHIP` is not.
## User-Configurable Error Codes
While most aspects of `libsdlerror` are intentionally fixed, error codes themselves are user-defined.
### Defining `ERR_*` Codes
```c
} PROCESS(errctx) {
} HANDLE(errctx, ERR_IO) {
} HANDLE_GROUP(errctx, ERR_KEY) {
} HANDLE_GROUP(errctx, ERR_INDEX) {
// This code handles 3 error cases
} HANDLE(errctx, ERR_RELATIONSHIP) {
// This code handles 1 error case
}
#define ERR_OK 0
#define ERR_SDL 1
#define ERR_IO 2
#define ERR_CONFIG 3
```
# Returning success or failure from functions returning ErrorContext *
These are simple integer status values.
If at all possible, when using this library, your functiions should return `ErrorContext *`. When returning from such functions, you should use the `SUCCEED_RETURN` and `FAIL_RETURN` macros.
---
## SUCCEED_RETURN
### `MAX_ERR_VALUE`
This macro is used when your function has reached the end of its happy code path and is prepared to exit successfully. This sets the ErrorContext to a successful state and exits the function.
`MAX_ERR_VALUE` must be greater than or equal to the highest `ERR_*` value you define.
It determines the size of internal static tables used to map error codes to names.
If you add new error codes, you must update `MAX_ERR_VALUE` and recompile.
---
### Naming Errors: `error_name_for_status`
```c
PREPARE_ERROR(errctx);
ATTEMPT {
// ... stuff
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
error_name_for_status(ERR_IO, "I/O error");
error_name_for_status(ERR_CONFIG, "Configuration error");
```
## FAIL_RETURN
Names are copied into fixed-size static buffers and persist for the lifetime of the program.
If the code path in the current function reaches a state wherein an error must be set and the function must return early, you can use `FAIL_RETURN` to accomplish this. Note that this should not be used inside of an `ATTEMPT { ... }` block; this immediately exits the function, preventing a `CLEANUP { ... }` block from executing. This can be safely used from inside of a `CLEANUP` or `PROCESS` block, or from anywhere within the function not inside of an `ATTEMPT { ... }` block.
No allocation occurs.
The function allows you to provide printf-style variable arguments to provide a meaningful failure message.
---
```c
PREPARE_ERROR(errctx);
FAIL_RETURN(ERR_BEHAVIOR, "Something went horribly wrong!")
```
## Appendix: Memory Model
## Conditionally failing and returning
### Storage Duration Overview
In addition to `FAIL_RETURN` you can also test for zero or non-zero conditions, set an error, and return from the function immediately. Use the `FAIL_ZERO_RETURN` and `FAIL_NONZERO_RETURN` macros for this. These macros can be used anywhere that `FAIL_RETURN` can be used.
`libsdlerror` uses only automatic (stack) storage and static storage duration.
```c
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (somePointer == NULL), ERR_NULLPOINTER, "Someone gave me a NULL pointer")
```
No heap allocation occurs at runtime.
```c
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(errctx, strcmp("not", "equal"), ERR_VALUE, "Strings are not equal")
```
---
# Uncaught errors
### Compile-Time Limits (`MAX_ERR*`)
## Ensuring that all error codes are captured
The `MAX_ERR*` macros in `sdlerror.h` define fixed upper bounds for:
Any function which returns `ErrorContext *` should also be marked with `ERROR_NOIGNORE`.
- Error message length
- Error name length
- Stacktrace buffer size
- Number of error contexts
```c
ErrorContext ERROR_NOIGNORE *f(...);
```
These values are not user-tunable at runtime.
This will cause a compile-time error if the return value of such a function is not used. "Used" here means assigned to a variable - it does not necessarily mean that the value is checked. However assuming that such functions are called inside of `ATTEMPT { ... }` blocks, it is safe to assume that such returns will be caught with `CATCH(...)`; therefore this error is a generally effective safeguard against careless coding where errors are not checked.
If you need different limits, you are expected to edit the header and recompile.
Beware that `ERROR_NOIGNORE` is not a failsafe - it implements the `warn_unused_result` mechanic. By design users may explicitly ignore an error code from a function marked with `warn_unused_result` by explicitly casting the return to `void`.
---
```c
#define ERROR_NOIGNORE __attribute__((warn_unused_result))
```
### Static Memory Usage (x86_64)
## Stack Traces
On a typical x86_64 system:
Whenever an error is captured using the `FAIL_*` or `CATCH` methods, and is unhandled such that it manages to propagate all the way to the top of the caller stack without being managed, the last `FINISH` macro to touch the error will trigger a stack trace and kill the program.
- Error name table: ~1 KB
- Static error pool: ~450 KB
- Miscellaneous globals: a few KB
Consider the `tests/err_trace.c` program which intentionally triggers this behavior. It produces output like this:
**Total static footprint: ~470 KB**
```
tests/err_trace.c:func2:7: 1 (Null Pointer Error) : This is a failure in func2
tests/err_trace.c:func2:10
tests/err_trace.c:func1:18: Detected error 0 from heap (refcount 1)
tests/err_trace.c:func1:18
tests/err_trace.c:func1:21
tests/err_trace.c:main:30: Detected error 0 from heap (refcount 1)
tests/err_trace.c:main:30
tests/err_trace.c:main:33: Unhandled Error 1 (Null Pointer Error): This is a failure in func2
```
All of this memory is allocated at program load time.
From bottom to top, we have:
---
## FAQ
### Why Use Static Globals Instead of the Heap?
Because error handling infrastructure must not itself fail.
Heap allocation introduces failure modes, ordering problems, and unpredictability.
Static storage avoids all of this.
The globals used by `libsdlerror` are fixed-size, initialized at load time, and free of allocator dependencies.
---
### Why Not Allocate Per-Error Structures Dynamically?
Because error handling must work when memory is tight, during early startup, and during shutdown.
Dynamic allocation undermines all three.
---
## Summary
`libsdlerror` is deliberately conservative.
No jumps.
No heap allocation.
No hidden control flow.
Just explicit, disciplined error handling — the way C has always worked when used correctly.
* The last line printed is the `FINISH` macro call that triggered the stacktrace.
* Above that, the `CATCH()` inside of `main()` which caught the exception from `func1()` but did not handle it
* Above that, a statement that the error was detected in the `CATCH()` statement at the same line
* Above that, the `FINISH()` macro in the `func1` method which detected the presence of an unhandled error and returned it up the calling stack
* Above that, the `CATCH()` macro in the `func1` method which caught the error coming out of `func2()`
* Above that, a statement that the error was detected in the `CATCH()` statement at the same line
* Above that, the `FINISH()` macro in `func2()` which detected an unhandled error and passed it out of the function
* Above that, a reference to the line where the `FAIL()` macro set the error code and provided the message which is printed here