Skip to content

Latest commit

 

History

History
202 lines (167 loc) · 8.82 KB

design_decisions.md

File metadata and controls

202 lines (167 loc) · 8.82 KB

Design Decisions

As various design decisions are made, we will list pros, cons, and our choices here.

[TOC]

The value of null is 0

The null value is an unsigned integer with a default width of 32. This can be set per class, e.g.

class Foo:4 (self) {
    ...
}

In this case, the reference is a u4. The two clear choices for null are 0 and all 1's. In this case, it would be 0u4 or 0xfu4.

Pros

  • Comparing against 0 is faster than comparing against an array's size, and in some cases slightly fast than comparing to all 1's.
  • Having an all 1's null means we cannot simply zero out an array or structure and have correct default values for objects. The compiler would have to generate code to explicitly set all null values at runtime.
  • Unless a user casts an integer to am object reference, there should never be an invalid object index, other than null, so we should not have to compare against the array size before indexing.

Cons

  • For even stronger debugging in debug mode, it would be nice to detect array index out of bounds for invalid object references. This can help track down compiler bugs or cases where users incorrectly cast an integer to an object reference.
  • We waste the memory in location 0 of all arrays backing member data. We could fix this in the future, with some more complexity in the compiler.

The default integer type is u64

This is somewhat jarring to new users, who naturally assume the default integer type should be able to represent negative numbers like -1. Justification for this choice requires taking a look at what happened when we tried to change the default to i64. Because we often return u64 from functions that return sizes, making i64 the default placed a burden on the user, causing them to need many more integer casts than when making u64 the default.

Pros

  • Users have fewer casts in their code when u64 is the default
  • Unsigned overflow detection is faster than signed overflow detection on Intel/AMD x64 architectures.

Cons

  • Unsigned default integer values can be jarring for new Rune users.

Exception handling

We'll differentiate between exceptions and errors like this:

  • Errors are often recoverable. A server should not crash because of invalid arguments in an RPC, but it should abandon the request, log the error, and return a Status.InvalidArgument status and some helpful text to the caller. Recoverable errors should generally raise Status enumerated values.
  • Exceptions often raised by generated code, such as overflow detection or calls to panic. It may be preferable not to recover from such events rather than risk data corruption. Handlers can still be useful, for example to gracefully restart a service, or to clip an audio output. Exceptions include index out of bounds, out of memory, numerical overflow and divide by zero. All exceptions generated by the Rune compiler raise an Exception enumerated value.

In Rune we raise both errors and exceptions, (sticking with the Python keyword). There are two builtin enumerated types that should cover most cases:

Internally, Rune exceptions are raised with a fixed-format value:

struct ExceptionStruct {
    errorEnumName: string = ""
    errorValueName: string = ""
    errorMessage: string = ""
    filePath: string = ""
    line: u32 = 0u32
}

One such struct is allocated per thread in thread-local memory. When we support fibers, it likely needs to be per-fiber.

Users are encouraged to use the standard Status enum, which avoids having to translate from multiple enums returned from called functions, and is compatible with C++ absl::Status. Exception handling in Rune is similar to using C++ absl::StatusOr<T> everywhere, but the compiler invisibly inserts the ASSIGN_OR_RETURN, RETURN_IF, and StatusOr<T> for you.

Functions declared exportlib or importlib (a shared library API), must declare what exception types can be raised, with syntax like:

rune` func raisesStuff() -> u32 raises Status, MyCustomeExceptionType { ...}

This enables the Rune compiler to see which exceptions are handled in try/except statements, and which are not handled. Only exceptions that are handled are raised. The others cause a stack trace to be printed, and the program to be aborted. TODO: add a hook for shutting down cleanly, for example, return RPC error status for RPCs still in flight.

Having a fixed ExceptionStruct format is a compromise between flexibility and maintaining ABI compatibility when dependent Rune libraries change. Status changes should be rare ABI breaking events, while custom enums and custom enum values change often. Because of this, raising custom status types should be avoided in the APIs of Rune libraries that need ABI compatibility, preferring to return Status. While Exception errors can be handled, their use should be limited to use cases where exiting is not desirable when the exception occurs, such as clipping audio output of a FIR filter processing fixed-point integer audio, rather than wrapping.

Non-transactional RPCs should raise Status errors, and callers should handle them. Transactional RPC calls should be dealt with differently, but this is a different topic.

enum HttpError {
    Ok = 0u32
    NotFound = 404u32
    ...
}

// Raises Exception(<u32>Exception, <u32>Exception.Unknown, "My code is having a bad day")
raise "My code is having a bad day"
// Raises Exception(<u32>Exception, <u32>Status.InvalidArgument, "Your code passed me bad data")
raise Status.InvalidArgument, "Your code passed me bad data"
// Raises Exception(<u32>HttError, <u32>HttpError.NotFound, "No web page at
www.example.com"
raise HttpError.NotFound, "No web page at ", "www.example.com"

Matching exception in except statements match either enum values, or enum types.

Nominally, Errors and Exceptions are implemented the same: the stack is unwound through implicit, compiler-generated return-if-an-error-happened statements. However, since Rune is a whole library optimizing compiler, it can determine if an exception or error won't be caught when raised from a given call site. In such a case, the error or exception would unwind to the very top-level and terminate the program. If Rune can detect such an occurrence, it can optimize instead and simply generate a default stack trace (with secrets redacted) and abort.

Therefore Rune may need to compile a given function twice, one for this optimized abort, and the other for regular stack unwinding.

try {
    jsonPayload = getJsonResponse(url)
} except e {
  // Cases are comma-separated Enum or Enum values.
  Status.InvalidArgument => raise e  // Propagates current error up the stack.
  HttpError.NotFound => {
      Log(LogType.Info, "Web page not found: %s" % url)
      return (null(Json), e.errorValue)
  }
  HttpError => return (null(Json), e.error)
  default => {
      // If default is not given, it will propagate the error up the stack.
      Log(LogType.Error, e.errorMesssage)
      return (null(Json), HttpStatus.Unkown)
  }
} finally {
  // For anything needing cleanup before propagating the error.
}

Rationale

Exception handling is a very complex topic. See:

We can't quite just do what Python does, since Python has access to data types while running. Our environment is closer to C++ and Rust.

In C++, divergent error handling has fractured the community into groups with incompatible code bases. Even a modern language like Nim struggles with it. The Rust community seems to devote more code to error handling than C, C++, or Python, because the standard method of returning Result<T, E> is too inflexible, returning only one error type. A lot of the Rust error handling code is devoted to translation between error types as a result.

The second paper linked above covers this complex topic in detail, and Rune is essentially following the author's recommendation: Exceptions should be raised by a statically typed value, not a dynamic type. Rune encourages use of Status by default, especially at the ABI level. Consumers of ABI-level error types should not break when those error types change, so reasonable default handling should be provided.

Raising errors is Rune-specific. Auto-generated wrappers for supported languages should catch the errors and return an appropriate value, or raise an appropriate error. For example, Google code often expects an absl::StatusOr<T>.