Skip to content

Error Handling

  1. Zero overhead when no exception is thrown
  2. Precise source mapping for debuggable error messages
  3. Robust unwinding that correctly handles nested try-catch blocks
  4. Clean integration with host (JVM) exceptions from FFI calls

Instead of maintaining a runtime stack of exception handlers (push on try, pop on exit), Nox uses a table-driven approach, the same technique used by modern JVMs and C++ compilers.

The compiler generates a metadata table separate from the bytecode. Each row maps a range of instructions to a catch handler:

ColumnDescription
Start PCFirst instruction of the try block
End PCLast instruction of the try block
Exception TypeThe specific error type to catch (or ANY for catch-all)
Target PCThe instruction address of the catch block
Message RegisterThe register where the error message should be stored
try { // PC 100
json data = Http.get(url); // PC 110
process(data); // PC 120
} catch (NetworkError e) { // PC 200
yield "Network failed: " + e;
} catch (TypeError e) { // PC 250
yield "Bad data: " + e;
}

Compiled Exception Table:

Start PCEnd PCTypeJump TargetMessage Reg
100150NetworkError200Reg 5
100150TypeError250Reg 6

When code inside a try block executes without errors, the Exception Table is never consulted. The VM simply executes instructions sequentially through the try block and continues past the catch blocks (which are skipped via a JMP instruction the compiler places at the end of the try block).

PC 100: [try block starts]
PC 110: GET_JSON ...
PC 120: CALL process
PC 148: JMP 300 Skips catch blocks entirely
PC 150: [try block ends]
PC 200: [catch NetworkError] Never reached on happy path
PC 250: [catch TypeError] Never reached on happy path
PC 300: [code continues]

There are no PUSH_HANDLER or POP_HANDLER instructions. Allowing the happy path to execute exactly as it would have if there were no try-catch at all.

When an exception occurs (from a THROW opcode, a failed SCALL, or a host JVM exception):

Exception occurs at PC 120
┌─────────────────────────┐
│ 1. VM pauses execution │
│ Save current PC │
└──────────┬──────────────┘
┌──────────────────────────────────┐
│ 2. Scan Exception Table │
│ Find row where: │
│ Start PC ≤ 120 ≤ End PC │
│ AND exception type matches │
└──────────┬───────────────────────┘
┌───┴───┐
Match? No Match?
│ │
▼ ▼
┌────────────┐ ┌──────────────────┐
│ 3a. Store │ │ 3b. Unwind │
│ error msg │ │ Pop call frame │
│ in Message │ │ Check parent │
│ Register │ │ function's table │
└─────┬──────┘ └────────┬─────────┘
│ │
▼ (repeat until
┌────────────┐ caught or top-level)
│ 4. Jump to │
│ Target PC │
└─────┬──────┘
┌────────────┐
│ 5. Resume │
│ execution │
│ in catch │
└────────────┘
  1. Pause: The VM saves the current program counter and exception details
  2. Scan: The VM iterates through the Exception Table, looking for a matching row:
    • The current PC must fall within the row’s [Start PC, End PC] range
    • The exception type must match (or the row catches ANY)
  3. Match Found:
    • The error message is written to the designated register: rMem[bp + messageReg] = exception.getMessage()
    • The program counter is set to the Target PC
    • Execution resumes inside the catch block
  4. No Match (Unwinding):
    • The current function’s call frame is popped
    • The exception is re-thrown in the caller’s context
    • The caller’s Exception Table is scanned
    • This continues until either a handler is found or the exception reaches main, causing the program to terminate with an error

When multiple catch blocks exist for the same try, the table contains multiple rows with the same Start PC/End PC but different types and targets:

Start PCEnd PCTypeTargetReg
100150NetworkError200R5
100150TypeError250R6
100150ANY300R7

The VM checks rows in order. The first match wins. A catch-all (ANY) always goes last (except resource guard exceptions).

The catch variable is populated through a simple register write:

catch (NetworkError errMsg) {
// errMsg is available as a string in the designated register
yield "Failed: " + errMsg;
}

Compiler mapping:

  • errMsg to Reg 5 (as declared in the Exception Table)
  • When the exception is caught: rMem[bp + 5] = exception.getMessage()
  • Inside the catch block, errMsg reads from Reg 5 like any other local variable

When a system call (SCALL) or super-instruction (HMOD) triggers a JVM exception, the VM must contain it:

SCALL -> {
try {
nativeFunc.invoke(pMem, rMem, bp, bpRef, args, destReg)
} catch (t: Throwable) {
// Convert ANY JVM exception into a Nox exception
val noxEx = NoxException(
classifyException(t), // Map to Nox error type
t.message,
currentPC
)
handleException(noxEx) // Triggers the table scan
}
}

This ensures:

  • A buggy plugin cannot crash the Host
  • JVM exceptions are translated into Nox’s error type system
  • Stack traces map back to NSL source locations, not JVM internals

All errors are reported with full source context, using the line/column metadata preserved from the ANTLR parse:

{
"error": {
"type": "RuntimeError",
"subtype": "NetworkError",
"message": "Connection refused: http://api.example.com/data",
"location": {
"file": "data_fetcher.nox",
"line": 12,
"column": 20,
"snippet": "11 | yield \"Fetching data...\";\n12 | json data = Http.get(url);\n | ^\n13 | process(data);"
},
"suggestion": "Verify the URL is reachable and the server is running."
}
}

Every exception in Nox has a type string used for matching in the Exception Table. The type system is flat i.e. there is no inheritance hierarchy (saves me from a lot of pain). A catch-all (ANY) catches everything (except resource guard exceptions).

These are thrown by VM operations during normal execution. All are catchable with try-catch.

TypeThrown WhenExample
NullAccessErrorAccessing a property or method on a null referencenull.length(), null.upper()
DivisionByZeroErrorDivision by zeroInteger division or modulo division by zero
TypeErrorType mismatch during extraction or conversionjson.getInt("key") on a string value
IndexOutOfBoundsErrorArray index is negative or ≥ array lengtharr[arr.length()], arr[-1]
KeyNotFoundErrorAccessing a missing key on json without a defaultdata.missingKey via HACC on a non-existent key
CastErrorAn as cast fails structural validationrawJson as ApiConfig when fields are missing or mistyped
TypeErrorRuntime type mismatch in a dynamic contextjson value is a string but code expects int
ArithmeticErrorNumeric overflow or invalid arithmetic operationInteger overflow on multiplication

These are thrown by standard library functions (via SCALL) when external operations fail. All are catchable.

TypeThrown WhenExample
NetworkErrorHTTP request fails (connection refused, timeout, DNS failure, non-2xx status)Http.getJson("https://bad.url")
FileErrorFile operation fails (not found, OS-level permission denied, I/O error)File.read("/nonexistent.txt")
ParseErrorData parsing fails (malformed JSON, invalid number format)Http.getJson(url) when response isn’t valid JSON
SecurityErrorPermission denied by the Host’s permission handlerFile.write(path) when file.write permission is denied

These are thrown by the VM’s watchdog system when execution exceeds configured limits. They are catchable (except by catch-all catch (err) blocks) but intended to terminate execution.

When a limit is reached and denied, the VM temporarily bumps the quota by a small “grace period” (using a capped exponential backoff, e.g., new_quota = old_quota + min(old_quota, 10000)). This allows the VM to transition to the catch block and perform cleanup. The program will terminate the moment the catch block finishes, as such catch blocks are automatically terminated with a special KILL instruction.

TypeThrown WhenDefault Limit
QuotaExceededErrorInstruction counter exceeds maxInstructions500,000 instructions
TimeoutErrorWall-clock time exceeds maxExecutionTime60 seconds
MemoryLimitErrorAllocated memory exceeds maxMemoryConfigurable
StackOverflowErrorCall stack depth exceeds maxCallDepth256 frames

Thrown explicitly in NSL code via the throw keyword.

throw "Something went wrong"; // Type: Error
throw `User ${name} not found`; // Type: Error
TypeThrown When
ErrorAny throw statement in user code. The thrown expression becomes the error message.

When a plugin (FFI call) throws a JVM exception, the VM translates it into a Nox exception type using a classifyException() function:

JVM ExceptionNox Type
NullPointerExceptionNullAccessError
ArithmeticExceptionArithmeticError
IndexOutOfBoundsExceptionIndexOutOfBoundsError
ClassCastExceptionCastError
IOExceptionFileError
java.net.* exceptionsNetworkError
SecurityExceptionSecurityError
IllegalArgumentExceptionTypeError
Any other ThrowableError
  1. The VM scans the Exception Table in order for the current pc
  2. A typed catch matches if exceptionType.equals(row.type)
  3. A catch-all (ANY) matches any exception type (except resource guard exceptions)
  4. If no match is found in the current function, the exception propagates to the caller
  5. If no match is found in main, the program terminates and the error is reported to the Host
try {
json data = Http.getJson(url);
ApiConfig config = data as ApiConfig;
} catch (NetworkError e) {
// Catches only NetworkError
} catch (CastError e) {
// Catches only CastError
} catch (err) {
// Catches everything else (NullAccessError, TypeError, etc.)
}