Exception handing in JIT: unwind-based vs. return-based approach?
Hi there!
I'd like to open a discussion around the current exception-handling mechanism under the JIT path, and propose an alternative that could simplify both the implementation and future extensibility.
Current Behavior
When an unhandled Codon exception is raised in a JIT-compiled function, the control flow goes through two layers of exception throwing:
Codon exception (e.g. raise IndexError)
→ seq_throw()
→ _Unwind_RaiseException(SEQ_EXCEPTION_CLASS)
→ no matching handler found → fall through
→ seq_terminate()
→ AOT path: print diagnostics and call abort()
→ JIT path: throw runtime::JITErrorFor example, the following snippet:
import codon
@codon.jit
def func():
x = []
x.pop() # raise IndexError
func()produces output like:
Traceback (most recent call last):
...
...codon_jit.JITError: IndexError: /xxx/stdlib/internal/types/collections/list.codon:337:13: pop from empty listIssues with the Current Approach
- Implicit assumption of no handler. The first
_Unwind_RaiseExceptioncall implicitly relies on the absence of any other codon-exception catch handler so that execution eventually reachesseq_terminate(the same assumption holds in the AOT path as well). This makes it significantly harder to integrate foreign exception-handling mechanisms — for instance, adding pybind11 bindings that need to intercept and translate Codon exceptions into Python exceptions. - Personality routine complexity. If we want to propagate typed exceptions (rather than just a single opaque
JITError), we would need to implement a full personality routine. Supporting such a routine correctly across different platforms (macOS / Linux / Windows, various unwinder ABIs) introduces considerable complexity.
Proposed Alternative: Explicit Return-Based Error Propagation
Drawing inspiration from Rust's approach, I'd like to propose that — at least for the JIT path — the Codon compiler lower raise, try/except/finally, and exception propagation into an explicit CFG with a status-return calling convention, eliminating the need for stack unwinding entirely.
Conceptual Model
Every Codon function that may raise an exception would be lowered to a noexcept function returning a status code:
enum SeqStatus : int32_t {
SEQ_OK = 0,
SEQ_ERROR = 1,
};
SeqStatus function(
ReturnType *result,
SeqException **error,
Arguments...
) noexcept;For example, the logical Codon code:
def divide(a: int, b: int) -> int:
if b == 0:
raise ZeroDivisionError()
return a // bwould be lowered to something equivalent to:
SeqStatus divide(int64_t *result, SeqException **error,
int64_t a, int64_t b) noexcept {
if (b == 0) {
*error = makeZeroDivisionError();
return SEQ_ERROR;
}
*result = a / b;
return SEQ_OK;
}And the caller site:
SeqException *error = nullptr;
int64_t result;
if (divide(&result, &error, a, b) != SEQ_OK)
return propagate(error);This is semantically identical to Rust's:
let result = divide(a, b)?;Why This Could Be Worth It
- Simplicity. No dependency on platform-specific unwind libraries or personality routines — just ordinary function calls and branches.
- Portability. The mechanism is pure C-level ABI; it works identically on every target without platform-specific unwind support.
- Extensibility. Foreign code (e.g., a
pybind11bridge or others) can inspect theSeqException*directly and translate it into whatever exception model the host language expects, with no need to intercept or re-raise unwind exceptions. - Performance. The overhead of an extra return-value check on the happy path is minimal and may, in practice, be comparable to — or even lower than — the cost of maintaining landing pads and LSDA tables required by the current unwind-based approach.
I'd love to hear the maintainers' and the community's thoughts on this. Happy to contribute to the implementation if there's interest!
Thanks for your time.
Source: exaloop/codon