Skip to content

Commit 95b8b14

Browse files
2kai2kai2sxlijin
andauthored
Host callable errors (#3680)
When a host function called from BAML throws an error, we can take one of three paths: 1. If the error type violates the BAML error type contract, then a `HostContractViolation` panic is raised in BAML 2. If the error type is convertible into a BAML type (e.g. codegenned type or primitive) then it is raised in BAML 3. Otherwise the error type is not convertible and is saved to the host value table. The handle to it wrapped in a `HostCallable` BAML error which is thrown in BAML. If the `HostCallable` is returned back out to the host then it will get the value from the table. This also includes making sysop errors properly catchable: previously all sysop errors were just converted into fatal internal engine errors. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Native host exceptions can round-trip and be rehydrated by SDKs; Node/Python SDKs expose registry/lookup helpers to preserve original Error identity. * Typed host-throws supported and a new HostContractViolation panic reports host contract mismatches. * **Bug Fixes** * IO/env/filesystem/http errors now surface clearer categories (e.g., InvalidArgument, ParseError) for non-UTF‑8 and invalid inputs. * Async/bridge error paths unified so thrown payloads, tracebacks, and error propagation behave more consistently. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Sam Lijin <sam@boundaryml.com>
1 parent d00da2b commit 95b8b14

92 files changed

Lines changed: 6446 additions & 3714 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

baml_language/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

baml_language/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ web-time = "1.1.0"
265265
getrandom = { version = "0.2" }
266266
getrandom_03 = { package = "getrandom", version = "0.3", features = [ "wasm_js" ] }
267267
num-bigint = { version = "0.4", features = [ "rand", "serde" ] }
268+
num-traits = { version = "0.2" }
268269
rsa = { version = "0.9", default-features = false, features = [ "std", "pem" ] }
269270
sha2 = { version = "0.10", features = [ "oid" ] }
270271

baml_language/crates/baml_builtins2/baml_std/baml/ns_env/env.baml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/// Returns the value of the environment variable `key`, or `null` if not set.
2-
function get(key: string) -> string? throws root.errors.Io {
2+
///
3+
/// Throws `ParseError` if the variable is set but its value is not valid
4+
/// UTF-8 (the native bridge surfaces `std::env::VarError::NotUnicode` as a
5+
/// catchable parse error).
6+
function get(key: string) -> string? throws root.errors.Io | root.errors.ParseError {
37
$rust_io_function
48
}
59

baml_language/crates/baml_builtins2/baml_std/baml/ns_errors/errors.baml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,22 @@ class DevOther {
5050
message string
5151
}
5252

53-
/// The host runtime panicked.
54-
class HostPanic {
55-
message string
56-
}
57-
5853
class HostCallable {
5954
message string
6055
class_name string
6156
language string
6257
traceback string?
63-
category int
58+
// Opaque slot carrying the bridge SDK's same-host rehydration handle.
59+
// Always populated on the wire by the originating bridge — a `HostCallable`
60+
// with no `_handle` is not a valid BAML value. The originating bridge
61+
// can resolve the handle back to the original native exception object
62+
// (same-process round-trip → `raised === caught` identity); foreign
63+
// runtimes receive the same handle on the wire but their local lookup
64+
// misses, at which point the host SDK degrades to constructing an
65+
// exception from the metadata fields above. The fallback is a
66+
// *lookup-result* concern on the host side, not a wire shape — the
67+
// field itself is required.
68+
_handle $rust_type
6469
}
6570

6671
/// A catch-all SDK-boundary failure with no more specific class. Synthesized

baml_language/crates/baml_builtins2/baml_std/baml/ns_fs/fs.baml

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,51 @@
11
/// A handle to an open file. Use `baml.fs.open` to obtain one.
2+
///
3+
/// Every `File.*` method can throw `InvalidArgument` when the underlying
4+
/// handle has already been closed (e.g. by a prior `close()`); read-string
5+
/// methods can additionally throw `ParseError` on non-UTF-8 input.
26
class File {
37
_handle $rust_type
48

59
/// Reads the entire remaining file contents as a UTF-8 string.
6-
function text(self) -> string throws root.errors.Io {
10+
function text(self) -> string throws root.errors.Io | root.errors.InvalidArgument | root.errors.ParseError {
711
$rust_io_function
812
}
913

1014
/// Reads the entire remaining file contents as raw bytes.
11-
function bytes(self) -> uint8array throws root.errors.Io {
15+
function bytes(self) -> uint8array throws root.errors.Io | root.errors.InvalidArgument {
1216
$rust_io_function
1317
}
1418

1519
/// Reads up to `n` bytes from the file and returns them as a UTF-8 string.
16-
function read(self, n: int) -> string throws root.errors.Io {
20+
function read(self, n: int) -> string throws root.errors.Io | root.errors.InvalidArgument | root.errors.ParseError {
1721
$rust_io_function
1822
}
1923

2024
/// Reads up to `n` bytes from the file and returns them as raw bytes.
21-
function read_bytes(self, n: int) -> uint8array throws root.errors.Io {
25+
function read_bytes(self, n: int) -> uint8array throws root.errors.Io | root.errors.InvalidArgument {
2226
$rust_io_function
2327
}
2428

2529
/// Closes the file handle, flushing any pending writes.
26-
function close(self) -> null throws root.errors.Io {
30+
function close(self) -> null throws root.errors.Io | root.errors.InvalidArgument {
2731
$rust_io_function
2832
}
2933

3034
/// Moves the file cursor. `whence` is `"start"`, `"current"`, or `"end"`. Returns the new cursor position in bytes.
31-
function seek_from(self, whence: "start" | "current" | "end", offset: int) -> int throws root.errors.Io {
35+
///
36+
/// Throws `InvalidArgument` if `offset` is negative when paired with
37+
/// `whence="start"` (the underlying syscall takes an unsigned offset).
38+
function seek_from(self, whence: "start" | "current" | "end", offset: int) -> int throws root.errors.Io | root.errors.InvalidArgument {
3239
$rust_io_function
3340
}
3441

3542
/// Writes `data` to the file at the current cursor position. Returns the number of bytes written.
36-
function write(self, data: string) -> int throws root.errors.Io {
43+
function write(self, data: string) -> int throws root.errors.Io | root.errors.InvalidArgument {
3744
$rust_io_function
3845
}
3946

4047
/// Writes raw bytes `data` to the file at the current cursor position. Returns the number of bytes written.
41-
function write_bytes(self, data: uint8array) -> int throws root.errors.Io {
48+
function write_bytes(self, data: uint8array) -> int throws root.errors.Io | root.errors.InvalidArgument {
4249
$rust_io_function
4350
}
4451
}
@@ -47,7 +54,7 @@ class File {
4754
///
4855
/// Mode values: `"r"` (read), `"r+"` (read/write), `"w"` (write/truncate),
4956
/// `"w+"` (read/write/truncate), `"a"` (append), `"a+"` (read/append).
50-
function open(path: string, mode: "r" | "r+" | "w" | "w+" | "a" | "a+") -> File throws root.errors.Io {
57+
function open(path: string, mode: "r" | "r+" | "w" | "w+" | "a" | "a+") -> File throws root.errors.Io | root.errors.InvalidArgument {
5158
$rust_io_function
5259
}
5360

@@ -67,7 +74,8 @@ function size(path: string) -> int throws root.errors.Io {
6774
}
6875

6976
/// Reads the entire contents of the file at `path` as a UTF-8 string.
70-
function read(path: string) -> string throws root.errors.Io {
77+
/// Throws `ParseError` if the file's bytes are not valid UTF-8.
78+
function read(path: string) -> string throws root.errors.Io | root.errors.ParseError {
7179
$rust_io_function
7280
}
7381

baml_language/crates/baml_builtins2/baml_std/baml/ns_host/host.baml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
/// Internal: invoked only by compiler-synthesized host-callable wrapper
22
/// closures. Not directly callable by user code.
33
///
4-
/// `T` is the expected return type, delivered to the native handler at
5-
/// runtime via the type-arg channel. The handler validates both the
6-
/// inbound `args` and the host's returned value against the declared
7-
/// signature, raising `root.errors.HostCallable` on mismatch.
8-
function call_host_value<T>(handle: HostValue, args: unknown[])
9-
-> T throws root.errors.HostCallable {
4+
/// `T` is the expected return type and `E` the declared error contract,
5+
/// both delivered to the native handler at runtime via the type-arg channel
6+
/// (`type_arg_0` / `type_arg_1`). The completion site validates the host's
7+
/// returned value against `T` and the thrown value against `E`: a return
8+
/// that doesn't inhabit `T` and a throw that isn't a subtype of `E` both
9+
/// become `baml.panics.HostContractViolation` (uncatchable). An on-contract
10+
/// throw rides the VM's normal exception unwinder. A host value left
11+
/// untyped at the boundary erases `E` to `unknown`, which accepts any
12+
/// thrown value — including an opaque `baml.errors.HostCallable`.
13+
function call_host_value<T, E>(handle: HostValue, args: unknown[])
14+
-> T throws E {
1015
$rust_io_function
1116
}
1217

baml_language/crates/baml_builtins2/baml_std/baml/ns_panics/panics.baml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,13 @@ class SdkPanic {
8484
message string
8585
}
8686

87-
type Panic = DivisionByZero | IndexOutOfBounds | InvalidFieldAccess | MapKeyNotFound | StackOverflow | AssertionFailed | Unreachable | Cancelled | UserPanic | Exit | AllocFailure | HostUnavailable | NegativeBitShift | SdkPanic
87+
/// A host callable violated its typed contract: it returned a value of the
88+
/// wrong type, or threw a value that does not match its declared error
89+
/// contract. Catchable like any other panic.
90+
class HostContractViolation {
91+
message string
92+
class_name string?
93+
language string?
94+
}
95+
96+
type Panic = DivisionByZero | IndexOutOfBounds | InvalidFieldAccess | MapKeyNotFound | StackOverflow | AssertionFailed | Unreachable | Cancelled | UserPanic | Exit | AllocFailure | HostUnavailable | NegativeBitShift | SdkPanic | HostContractViolation

baml_language/crates/baml_builtins2_codegen/src/codegen_io.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -593,12 +593,33 @@ pub fn generate_sys_op_enum(io_builtins: &[NativeBuiltin]) -> String {
593593
let variant = format_ident!("{}", b.sys_op_variant_name());
594594
// `None` (no clause) is rejected during extraction; `Some([])`
595595
// (`throws never`) and `None` both map to no error categories.
596+
// A `throws` entry that names one of the builtin's generic params
597+
// (e.g. `call_host_value<T, E> ... throws E`) is a *dynamic* error
598+
// contract, not a fixed `SysOpErrorCategory`. The static category
599+
// list cannot represent a mix of "concrete X" + "dynamic E" — the
600+
// dynamic component would be silently dropped, leaving
601+
// `validate_sys_op_error` to wrongly reject any `E`-shaped throw
602+
// as off-contract. Apply an all-or-nothing rule: if *any* throws
603+
// entry is generic, emit an empty list ("accept any category")
604+
// and defer the entire contract check to runtime. Concrete-only
605+
// contracts still emit a precise category list. (The `throws`
606+
// clause stays non-empty either way, so the builtin remains
607+
// `is_fallible`.)
596608
match &b.throws {
597-
Some(cats) if !cats.is_empty() => {
598-
let cats: Vec<_> = cats.iter().map(|t| format_ident!("{}", t)).collect();
599-
quote! { SysOp::#variant => &[#(SysOpErrorCategory::#cats),*] }
609+
Some(cats) => {
610+
let has_generic_throw = cats.iter().any(|t| b.generics.iter().any(|g| g == t));
611+
if has_generic_throw {
612+
quote! { SysOp::#variant => &[] }
613+
} else {
614+
let cats: Vec<_> = cats.iter().map(|t| format_ident!("{}", t)).collect();
615+
if cats.is_empty() {
616+
quote! { SysOp::#variant => &[] }
617+
} else {
618+
quote! { SysOp::#variant => &[#(SysOpErrorCategory::#cats),*] }
619+
}
620+
}
600621
}
601-
_ => quote! { SysOp::#variant => &[] },
622+
None => quote! { SysOp::#variant => &[] },
602623
}
603624
})
604625
.collect();
@@ -1383,7 +1404,7 @@ fn emit_glue_method(
13831404
}
13841405
Err(e) => SysOpResult::Ready(Err(OpError::new(
13851406
SysOp::#variant_ident,
1386-
OpErrorKind::AccessError(e),
1407+
bex_vm_types::errors::VmBamlError::AccessError { message: e.to_string() },
13871408
))),
13881409
}
13891410
}
@@ -1739,7 +1760,7 @@ fn emit_free_fn_glue(
17391760
}
17401761
Err(e) => SysOpResult::Ready(Err(OpError::new(
17411762
SysOp::#variant_ident,
1742-
OpErrorKind::AccessError(e),
1763+
bex_vm_types::errors::VmBamlError::AccessError { message: e.to_string() },
17431764
))),
17441765
}
17451766
}
@@ -1816,7 +1837,9 @@ fn emit_sys_ops_struct(io_builtins: &[NativeBuiltin]) -> TokenStream {
18161837
t.get_sys_op_fn(#path_str, heap, permit, args, ctx, call_id)
18171838
.unwrap_or_else(|| SysOpResult::Ready(Err(OpError::new(
18181839
SysOp::#variant_ident,
1819-
OpErrorKind::Unsupported,
1840+
bex_vm_types::errors::VmBamlError::Unsupported {
1841+
message: "Operation not supported on this platform".to_string(),
1842+
},
18201843
))))
18211844
})
18221845
}
@@ -1841,7 +1864,9 @@ fn emit_sys_ops_struct(io_builtins: &[NativeBuiltin]) -> TokenStream {
18411864
std::sync::Arc::new(move |_, _, _, _, _| {
18421865
SysOpResult::Ready(Err(OpError::new(
18431866
operation,
1844-
OpErrorKind::Unsupported,
1867+
bex_vm_types::errors::VmBamlError::Unsupported {
1868+
message: "Operation not supported on this platform".to_string(),
1869+
},
18451870
)))
18461871
})
18471872
}

baml_language/crates/baml_builtins2_codegen/src/extract.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ mod tests {
10461046
.iter()
10471047
.find(|b| b.path == "baml.fs.open")
10481048
.unwrap();
1049-
assert_eq!(fs_open.throws, throws(&["Io"]));
1049+
assert_eq!(fs_open.throws, throws(&["Io", "InvalidArgument"]));
10501050

10511051
let net_connect = io_builtins
10521052
.iter()

baml_language/crates/baml_cli/src/snapshots/baml_cli__describe_command_tests__render_builtin_namespace_env.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
source: crates/baml_cli/src/describe_command_tests.rs
33
expression: output
44
---
5-
function env.get <builtin>/baml/ns_env/env.baml:2
6-
function env.get_or_panic <builtin>/baml/ns_env/env.baml:7
5+
function env.get <builtin>/baml/ns_env/env.baml:6
6+
function env.get_or_panic <builtin>/baml/ns_env/env.baml:11

0 commit comments

Comments
 (0)