Skip to content

Commit ec99ebf

Browse files
committed
feat(host): infer typed host call contracts from signatures
1 parent c3d6de4 commit ec99ebf

12 files changed

Lines changed: 223 additions & 62 deletions

File tree

build.rs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ pub(crate) enum HostBindingKind {
4949
StaticNonYieldingArgs,
5050
}
5151

52+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53+
pub(crate) enum HostExecutionKind {
54+
Sync,
55+
MaySuspend,
56+
}
57+
5258
impl HostBindingKind {
5359
pub(crate) fn render_bind_static_call(&self, name: &str, function_name: &str) -> String {
5460
let method = match self {
@@ -108,6 +114,7 @@ struct CallableDecl {
108114
static_return_type: String,
109115
wrapper: Option<WrapperDecl>,
110116
host_binding_kind: HostBindingKind,
117+
host_execution: HostExecutionKind,
111118
}
112119

113120
#[derive(Clone, Debug)]
@@ -261,7 +268,6 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind {
261268
return HostBindingKind::StaticArgs;
262269
}
263270
if sole_type_argument(&return_type, "VmResult")
264-
.or_else(|| sole_type_argument(&return_type, "HostResult"))
265271
.is_some_and(|inner| matches!(plain_path_type(&inner).as_deref(), Some("CallOutcome")))
266272
{
267273
return HostBindingKind::StaticArgs;
@@ -272,6 +278,22 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind {
272278
HostBindingKind::StaticArgs
273279
}
274280

281+
pub(crate) fn infer_host_execution(function: &ItemFn) -> HostExecutionKind {
282+
let return_type = normalized_return_type(&function.sig.output);
283+
if contains_host_call_result(&return_type) {
284+
HostExecutionKind::MaySuspend
285+
} else {
286+
HostExecutionKind::Sync
287+
}
288+
}
289+
290+
fn contains_host_call_result(ty: &Type) -> bool {
291+
if sole_type_argument(ty, "HostCallResult").is_some() {
292+
return true;
293+
}
294+
sole_type_argument(ty, "VmResult").is_some_and(|inner| contains_host_call_result(&inner))
295+
}
296+
275297
fn is_supported_ordinary_return_type(ty: &Type) -> bool {
276298
match ty {
277299
Type::Group(group) => is_supported_ordinary_return_type(&group.elem),
@@ -282,10 +304,8 @@ fn is_supported_ordinary_return_type(ty: &Type) -> bool {
282304
return false;
283305
};
284306
match segment.ident.to_string().as_str() {
285-
"Option" | "VmResult" | "HostResult" => {
286-
sole_type_argument(ty, &segment.ident.to_string())
287-
.is_some_and(|inner| is_supported_ordinary_return_type(&inner))
288-
}
307+
"Option" | "VmResult" => sole_type_argument(ty, &segment.ident.to_string())
308+
.is_some_and(|inner| is_supported_ordinary_return_type(&inner)),
289309
"Vec" => sole_type_argument(ty, "Vec")
290310
.is_some_and(|inner| is_supported_vec_return_type(&inner)),
291311
"Value" | "bool" | "i64" | "u32" | "usize" | "f64" | "String" | "str"
@@ -404,6 +424,7 @@ fn parse_source_file(path: &Path, spec: &SourceSpec, _order_offset: usize) -> Ve
404424
static_return_type: static_return_type_label(&function.sig.output),
405425
wrapper,
406426
host_binding_kind: classify_host_binding(function),
427+
host_execution: infer_host_execution(function),
407428
});
408429
}
409430
out
@@ -1145,9 +1166,13 @@ fn render_callable_consts(callables: &[&CallableDecl]) -> String {
11451166
.unwrap();
11461167
writeln!(
11471168
&mut out,
1148-
"#[allow(dead_code)]\nconst {base}_DEF: CallableDef = CallableDef {{ name: {:?}, docs: {:?}, signature: {base}_SIGNATURE }};",
1169+
"#[allow(dead_code)]\nconst {base}_DEF: CallableDef = CallableDef {{ name: {:?}, docs: {:?}, signature: {base}_SIGNATURE, host_execution: HostExecution::{} }};",
11491170
callable.name,
1150-
callable.docs
1171+
callable.docs,
1172+
match callable.host_execution {
1173+
HostExecutionKind::Sync => "Sync",
1174+
HostExecutionKind::MaySuspend => "MaySuspend",
1175+
}
11511176
)
11521177
.unwrap();
11531178
writeln!(&mut out).unwrap();
@@ -1997,7 +2022,7 @@ fn type_label(ty: &Type) -> String {
19972022
};
19982023
format!("{} | null", type_label(inner))
19992024
}
2000-
"VmResult" | "BuiltinResult" | "HostResult" => {
2025+
"VmResult" | "HostCallResult" => {
20012026
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
20022027
panic!("{ident}<T> requires one generic argument");
20032028
};

docs/plans/2026-07-16-automatic-host-binding-selection.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Run the narrow host/JIT tests. Expected: generated code still selects `bind_stat
6767
Add `HostBindingKind::{StaticStack, StaticArgs, StaticNonYieldingArgs}` in `build.rs`. Classify in this order:
6868

6969
1. any `Vm` parameter → `StaticStack`;
70-
2. normalized `CallOutcome`, including `VmResult<CallOutcome>` and `HostResult<CallOutcome>` `StaticArgs`;
70+
2. normalized `CallOutcome`, including `VmResult<CallOutcome>``StaticArgs`;
7171
3. all other valid args-only returns → `StaticNonYieldingArgs`.
7272

7373
Use that one classification in generated registry and direct VM binding code. The non-yielding branches must emit `register_static_non_yielding_args` and `bind_static_non_yielding_args_function`.

docs/superpowers/specs/2026-07-16-automatic-host-binding-selection-design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ This change covers `#[pd_host_function]` implementations discovered by the RustS
1313
Introduce one shared build-time classification with these ordered rules:
1414

1515
1. A function with a `Vm` context parameter uses `StaticStack`.
16-
2. An args-only function whose normalized return type is `CallOutcome`, including `VmResult<CallOutcome>` and `HostResult<CallOutcome>`, uses `StaticArgs`.
16+
2. An args-only function whose normalized return type is `CallOutcome`, including `VmResult<CallOutcome>`, uses `StaticArgs`.
1717
3. Every other valid args-only annotated function uses `StaticNonYieldingArgs`.
1818

19-
The third rule is safe because the generated wrapper converts all supported ordinary outputs through `IntoVmValue` into exactly one `Value`. This includes implicit `()`, explicit `()`, and `Option<T>`, which become `Value::Null` when appropriate. `VmResult<T>` and `HostResult<T>` may still return an error; successful calls return exactly one value synchronously.
19+
The third rule is safe because the generated wrapper converts all supported ordinary outputs through `IntoVmValue` into exactly one `Value`. This includes implicit `()`, explicit `()`, and `Option<T>`, which become `Value::Null` when appropriate. `VmResult<T>` may still return an error; successful calls return exactly one value synchronously.
2020

2121
`CallOutcome` stays on the general static args ABI because it can represent no return value, halt, yield, or pending work. Any signature that cannot be classified safely falls back to the general compatible static binding.
2222

pd-host-function/src/lib.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ fn parse_name_arg(args: &Punctuated<Meta, Token![,]>) -> Result<LitStr, Error> {
5353
"expected #[pd_host_function(name = \"...\")]",
5454
));
5555
};
56+
if args.len() != 1 {
57+
let extra = args
58+
.iter()
59+
.nth(1)
60+
.expect("a non-empty attribute with more than one argument has an extra argument");
61+
return Err(Error::new_spanned(
62+
extra,
63+
"#[pd_host_function] only supports name = \"...\"",
64+
));
65+
}
5666
if !name_value.path.is_ident("name") {
5767
return Err(Error::new_spanned(
5868
&name_value.path,
@@ -267,10 +277,7 @@ fn unwrap_vm_result_type(ty: &Type) -> Result<Option<Type>, Error> {
267277
let Some(segment) = path.path.segments.last() else {
268278
return Ok(None);
269279
};
270-
if !matches!(
271-
segment.ident.to_string().as_str(),
272-
"VmResult" | "HostResult"
273-
) {
280+
if segment.ident != "VmResult" {
274281
return Ok(None);
275282
}
276283
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
@@ -352,7 +359,7 @@ fn type_label(ty: &Type) -> Result<String, Error> {
352359
let inner_label = type_label(inner)?;
353360
Ok(format!("{inner_label} | null"))
354361
}
355-
"VmResult" | "BuiltinResult" | "HostResult" => {
362+
"VmResult" | "HostCallResult" => {
356363
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
357364
return Err(Error::new_spanned(
358365
&segment.arguments,
@@ -472,3 +479,58 @@ fn uses_taken_extractor(ty: &Type) -> bool {
472479
_ => false,
473480
}
474481
}
482+
483+
#[cfg(test)]
484+
mod tests {
485+
use super::expand_pd_host_function;
486+
use syn::{ItemFn, Meta, Token, parse_quote, punctuated::Punctuated};
487+
488+
#[test]
489+
fn accepts_host_call_result_from_the_function_signature() {
490+
let attr: Punctuated<Meta, Token![,]> = parse_quote!(name = "test::suspend");
491+
let item: ItemFn = parse_quote! {
492+
/// Returns a value after a host operation completes.
493+
#[pd_host_function(name = "test::suspend")]
494+
fn suspend() -> VmResult<HostCallResult<Value>> {
495+
todo!()
496+
}
497+
};
498+
499+
let expanded = expand_pd_host_function(attr, item)
500+
.expect("HostCallResult should be accepted from the return signature");
501+
assert!(expanded.to_string().contains("HostCallResult"));
502+
}
503+
504+
#[test]
505+
fn rejects_host_result_compatibility_wrapper() {
506+
let attr: Punctuated<Meta, Token![,]> = parse_quote!(name = "test::legacy");
507+
let item: ItemFn = parse_quote! {
508+
/// Legacy result wrapper must be rejected.
509+
#[pd_host_function(name = "test::legacy")]
510+
fn legacy() -> HostResult<Value> {
511+
todo!()
512+
}
513+
};
514+
515+
let error = expand_pd_host_function(attr, item)
516+
.expect_err("HostResult must not be accepted as a return wrapper");
517+
assert!(error.to_string().contains("unsupported callable type"));
518+
}
519+
520+
#[test]
521+
fn rejects_async_attribute_instead_of_treating_it_as_a_host_contract() {
522+
let attr: Punctuated<Meta, Token![,]> =
523+
parse_quote!(name = "test::suspend", r#async = true);
524+
let item: ItemFn = parse_quote! {
525+
/// Returns a value after a host operation completes.
526+
#[pd_host_function(name = "test::suspend")]
527+
fn suspend() -> VmResult<HostCallResult<Value>> {
528+
todo!()
529+
}
530+
};
531+
532+
let error = expand_pd_host_function(attr, item)
533+
.expect_err("the pd-host-function macro must not accept an async attribute");
534+
assert!(error.to_string().contains("only supports name"));
535+
}
536+
}

src/builtins/metadata.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,18 @@ pub struct CallableSignature {
4242
pub return_type: &'static str,
4343
}
4444

45+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46+
pub enum HostExecution {
47+
Sync,
48+
MaySuspend,
49+
}
50+
4551
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4652
pub struct CallableDef {
4753
pub name: &'static str,
4854
pub docs: &'static str,
4955
pub signature: CallableSignature,
56+
pub host_execution: HostExecution,
5057
}
5158

5259
#[allow(dead_code)]

src/builtins/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ mod metadata;
55
#[cfg(feature = "runtime")]
66
pub(crate) mod runtime;
77

8-
pub use self::metadata::{CallableDef, CallableParam, CallableParamType, CallableSignature};
8+
pub use self::metadata::{
9+
CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution,
10+
};
911
use crate::ValueType;
1012
#[cfg(feature = "runtime")]
1113
pub(crate) use crate::vm::{HostFunctionRegistry, Value, Vm, VmResult};

src/builtins/runtime/io.rs

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::task::{Context, Poll};
99
use futures_channel::oneshot;
1010
use pd_host_function::pd_host_function;
1111

12-
use super::BuiltinResult;
12+
use super::HostCallResult;
1313
use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult};
1414

1515
pub(crate) struct IoState {
@@ -87,7 +87,11 @@ pub(super) fn close_all_handles(vm: &mut Vm) {
8787

8888
/// Opens a file handle for runtime I/O.
8989
#[pd_host_function(name = "io::open")]
90-
pub(super) fn builtin_io_open(vm: &mut Vm, path: &str, mode: &str) -> VmResult<BuiltinResult<i64>> {
90+
pub(super) fn builtin_io_open(
91+
vm: &mut Vm,
92+
path: &str,
93+
mode: &str,
94+
) -> VmResult<HostCallResult<i64>> {
9195
let reserved_id = io_reserve_handle_id(vm);
9296
let path = path.to_string();
9397
let mode = mode.to_string();
@@ -133,7 +137,7 @@ pub(super) fn builtin_io_open(vm: &mut Vm, path: &str, mode: &str) -> VmResult<B
133137
},
134138
}
135139
})?;
136-
Ok(BuiltinResult::Pending(op_id))
140+
Ok(HostCallResult::Pending(op_id))
137141
}
138142

139143
/// Starts a child process and returns a process-backed handle.
@@ -142,7 +146,7 @@ pub(super) fn builtin_io_popen(
142146
vm: &mut Vm,
143147
command: &str,
144148
mode: &str,
145-
) -> VmResult<BuiltinResult<i64>> {
149+
) -> VmResult<HostCallResult<i64>> {
146150
if mode != "r" && mode != "w" {
147151
return Err(VmError::HostError(format!(
148152
"unsupported io_popen mode '{mode}', expected r or w"
@@ -191,12 +195,12 @@ pub(super) fn builtin_io_popen(
191195
result: Ok(CallReturn::one(Value::Int(reserved_id))),
192196
}
193197
})?;
194-
Ok(BuiltinResult::Pending(op_id))
198+
Ok(HostCallResult::Pending(op_id))
195199
}
196200

197201
/// Reads all remaining text from an I/O handle.
198202
#[pd_host_function(name = "io::read_all")]
199-
pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<BuiltinResult<String>> {
203+
pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<String>> {
200204
let handle = io_take_handle(vm, handle_id)?;
201205
let op_id = schedule_io_task(vm, move || {
202206
let mut handle = handle;
@@ -232,12 +236,15 @@ pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<Built
232236
result,
233237
}
234238
})?;
235-
Ok(BuiltinResult::Pending(op_id))
239+
Ok(HostCallResult::Pending(op_id))
236240
}
237241

238242
/// Reads a single line of text from an I/O handle.
239243
#[pd_host_function(name = "io::read_line")]
240-
pub(super) fn builtin_io_read_line(vm: &mut Vm, handle_id: i64) -> VmResult<BuiltinResult<String>> {
244+
pub(super) fn builtin_io_read_line(
245+
vm: &mut Vm,
246+
handle_id: i64,
247+
) -> VmResult<HostCallResult<String>> {
241248
let handle = io_take_handle(vm, handle_id)?;
242249
let op_id = schedule_io_task(vm, move || {
243250
let mut handle = handle;
@@ -268,7 +275,7 @@ pub(super) fn builtin_io_read_line(vm: &mut Vm, handle_id: i64) -> VmResult<Buil
268275
result,
269276
}
270277
})?;
271-
Ok(BuiltinResult::Pending(op_id))
278+
Ok(HostCallResult::Pending(op_id))
272279
}
273280

274281
/// Writes text to an I/O handle.
@@ -277,7 +284,7 @@ pub(super) fn builtin_io_write(
277284
vm: &mut Vm,
278285
handle_id: i64,
279286
text: &str,
280-
) -> VmResult<BuiltinResult<i64>> {
287+
) -> VmResult<HostCallResult<i64>> {
281288
let bytes = text.as_bytes().to_vec();
282289
let handle = io_take_handle(vm, handle_id)?;
283290
let op_id = schedule_io_task(vm, move || {
@@ -313,12 +320,12 @@ pub(super) fn builtin_io_write(
313320
result,
314321
}
315322
})?;
316-
Ok(BuiltinResult::Pending(op_id))
323+
Ok(HostCallResult::Pending(op_id))
317324
}
318325

319326
/// Flushes buffered output for an I/O handle.
320327
#[pd_host_function(name = "io::flush")]
321-
pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult<BuiltinResult<bool>> {
328+
pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<bool>> {
322329
let handle = io_take_handle(vm, handle_id)?;
323330
let op_id = schedule_io_task(vm, move || {
324331
let mut handle = handle;
@@ -351,31 +358,31 @@ pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult<BuiltinR
351358
result,
352359
}
353360
})?;
354-
Ok(BuiltinResult::Pending(op_id))
361+
Ok(HostCallResult::Pending(op_id))
355362
}
356363

357364
/// Closes an I/O handle.
358365
#[pd_host_function(name = "io::close")]
359-
pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult<BuiltinResult<bool>> {
366+
pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<bool>> {
360367
let handle = io_take_handle(vm, handle_id)?;
361368
let op_id = schedule_io_task(vm, move || IoAsyncCompletion {
362369
restored_handle: None,
363370
result: close_io_handle(handle).map(|_| CallReturn::one(Value::Bool(true))),
364371
})?;
365-
Ok(BuiltinResult::Pending(op_id))
372+
Ok(HostCallResult::Pending(op_id))
366373
}
367374

368375
/// Returns whether a file system path exists.
369376
#[pd_host_function(name = "io::exists")]
370-
pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult<BuiltinResult<bool>> {
377+
pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult<HostCallResult<bool>> {
371378
let path = path.to_string();
372379
let op_id = schedule_io_task(vm, move || IoAsyncCompletion {
373380
restored_handle: None,
374381
result: Ok(CallReturn::one(Value::Bool(
375382
std::path::Path::new(path.as_str()).exists(),
376383
))),
377384
})?;
378-
Ok(BuiltinResult::Pending(op_id))
385+
Ok(HostCallResult::Pending(op_id))
379386
}
380387

381388
fn spawn_shell_command(command: &str, mode: &str) -> VmResult<Child> {

0 commit comments

Comments
 (0)