Skip to content

Initial pass at new host ABI #7795

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
May 19, 2025
Next Next commit
Add host_abi.zig
  • Loading branch information
rtfeldman committed May 18, 2025
commit 47286b05ca241b873de8093b3fd06a569dd099e7
106 changes: 106 additions & 0 deletions src/host_abi.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/// All Roc functions that are exposed to the host take 1 argument and return void.
/// The 1 argument is a pointer to one of these structs, which includes an address
/// that the Roc call will write the return value into. This design makes Roc's ABI
/// very simple; the calling convention is just "pass a single pointer".
pub const RocCall = struct {
/// Function pointers that the Roc program uses, e.g. alloc, dealloc, etc.
ops: *RocOps,
Copy link
Contributor Author

@rtfeldman rtfeldman May 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ops pointer is what will actually get threaded through from one Roc function call to another behind the scenes.

arg and ret only get used in the outermost function wrapper that the host calls.

This means that having all of these be pointers is a minimal cost, because all of them except for ops will only get dereferenced once per host call.

/// The argument that the Roc entrypoint function will receive from the host.
/// (If multiple arguments are needed, this should be a pointer to a struct.)
arg: *anyopaque,
/// What the Roc entrypoint function will return to the host.
/// (The caller should have allocated enough space for the Roc function to write
/// the entire return value into this.)
ret: *anyopaque,
};

/// The operations (in the form of function pointers) that a running Roc program
/// needs the host to provide.
///
/// This is used in both calls from actual hosts as well as evaluation of constants
/// inside the Roc compiler itself.
pub const RocOps = struct {
/// Like _aligned_malloc (size, alignment) - https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/aligned-malloc
/// Roc will automatically call roc_crashed if this returns null.
roc_alloc: fn (*RocAlloc) void,
/// Like _aligned_free - https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/aligned-free
roc_dealloc: fn (*RocDealloc) void,
/// Like _aligned_realloc (ptr, size, alignment) - https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/aligned-realloc
/// Roc will automatically call roc_crashed if this returns null.
roc_realloc: fn (*RocRealloc) void,
/// Called when the Roc program crashes, e.g. due to integer overflow.
/// It receives a pointer to a UTF-8 string, along with its length in bytes.
/// This function must not return, because the Roc program assumes it will
/// not continue to be executed after this function is called.
roc_crashed: fn (*RocCrashed) void,
/// Called when the Roc program has called `dbg` on something.
roc_dbg: fn (*RocDbg) void,
/// Called when the Roc program has run an `expect` which failed.
roc_expect_failed: fn (*RocExpectFailed) void,
};

/// When RocOps.roc_alloc gets called, it will be passed one of these.
/// That function should write the allocated memory into `ret`.
/// If it cannot proivde a non-null pointer (e.g. due to OOM), it
/// must not return, and must instead do something along the lines
/// of roc_crashed.
pub const RocAlloc = struct {
alignment: usize,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this helps here since the struct has no padding, but in Zig std lib we've started using an Alignment type that stores the log2 value. It's pretty handy because you only need 6 bits to describe any alignment for a 64-bit address.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, that's a cool trick! I agree that it probably doesn't help here, but that's a good one to know for future reference. 🤘

length: usize,
ret: *anyopaque,
ops: *RocOps,
};
Copy link
Contributor Author

@rtfeldman rtfeldman May 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit different from how we're doing it in the Rust compiler. Some notes:

  • To avoid needing to get into the C calling convention at all, I continued using the "all functions take a single pointer and return void" thing with roc_alloc and friends.
    • The one exception is that I have them all take a second argument, the "env pointer" that's a pointer to whatever the host wants it to be - e.g. a pointer to an arena in case the host wants to use arena allocation for everything.
  • Since this is now different enough from the signature for something like malloc, I figured it would be fine to require that the host handle OOM itself rather than the Roc compiler generating an extra conditional to detect null and call roc_crashed if so. (All the hosts that anyone will use as templates to copy from will do this anyway, so I don't think it will be a problem.)


/// When RocOps.roc_dealloc gets called, it will be passed one of these.
/// (The length of the allocation cannot be provided, because it is
/// not always known at runtime due to the way seamless slices work.)
pub const RocDealloc = struct {
alignment: usize,
ptr: *anyopaque,
ops: *RocOps,
};

/// When RocOps.roc_realloc gets called, it will be passed one of these.
/// That function should write the allocated memory into `ret`.
/// If it cannot proivde a non-null pointer (e.g. due to OOM), it
/// must not return, and must instead do something along the lines
/// of roc_crashed.
pub const RocRealloc = struct {
alignment: usize,
new_length: usize,
ret: *anyopaque,
ops: *RocOps,
};

/// The UTF-8 string message the host receives when a Roc program crashes
/// (e.g. due to integer overflow).
///
/// The pointer to the UTF-8 bytes is guaranteed to be non-null,
/// but it is *not* guaranteed to be null-terminated.
pub const RocCrashed = struct {
utf8_bytes: *u8,
len: usize,
};

/// The information the host receives when a Roc program runs a `dbg`.
///
/// The pointer to the UTF-8 bytes is guaranteed to be non-null,
/// but it is *not* guaranteed to be null-terminated.
pub const RocDbg = struct {
// TODO make this be structured instead of just a string, so that
// the host can format things more nicely (e.g. syntax highlighting).
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure we can do the "structured dbg" and "structured expect failure" in a future PR. This is enough to unblock Hello World.

utf8_bytes: *u8,
len: usize,
};

/// The information the host receives when a Roc program runs an inline `expect`
/// which fails.
///
/// The pointer to the UTF-8 bytes is guaranteed to be non-null,
/// but it is *not* guaranteed to be null-terminated.
pub const RocExpectFailed = struct {
// TODO make this be structured instead of just a string, so that
// the host can format things more nicely (e.g. syntax highlighting).
utf8_bytes: *u8,
len: usize,
};