Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

207 changes: 206 additions & 1 deletion rust/crates/truapi-codegen/src/rustdoc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,8 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result<Opt
let raw_output = sig
.get("output")
.with_context(|| format!("Method `{name}` missing rustdoc return type"))?;
let output = raw_output;
let output = unwrap_future_output(raw_output)
.with_context(|| format!("Method `{name}` has an invalid Future return type"))?;

let (kind, return_type) = if is_result_subscription_return(output) {
(
Expand Down Expand Up @@ -831,6 +832,92 @@ fn is_subscription_return(output: &serde_json::Value) -> bool {
.unwrap_or(false)
}

/// Resolve the `Output = T` binding from a Send future method return.
///
/// `async_trait` represents `async fn` as
/// `Pin<Box<dyn Future<Output = T> + Send + 'async_trait>>` in rustdoc JSON.
/// Explicit `impl Future<Output = T> + Send` returns are also accepted so the
/// parser remains compatible with older TrUAPI trait snapshots.
fn unwrap_future_output(output: &serde_json::Value) -> Result<&serde_json::Value> {
if let Some(future_output) = extract_async_trait_future_output(output) {
return Ok(future_output);
}
let Some(bounds) = output
.get("impl_trait")
.and_then(serde_json::Value::as_array)
else {
return Ok(output);
};
let future = bounds
.iter()
.filter_map(|bound| bound.get("trait_bound"))
.filter_map(|bound| bound.get("trait"))
.find(|bound| {
bound
.get("path")
.and_then(serde_json::Value::as_str)
.is_some_and(|path| path_suffix(path) == "Future")
})
.context("impl Trait return is missing its Future bound")?;
let constraints = future
.get("args")
.and_then(|args| args.get("angle_bracketed"))
.and_then(|args| args.get("constraints"))
.and_then(serde_json::Value::as_array)
.context("Future bound is missing its associated-type constraints")?;
constraints
.iter()
.find(|constraint| {
constraint.get("name").and_then(serde_json::Value::as_str) == Some("Output")
})
.and_then(|constraint| constraint.get("binding"))
.and_then(|binding| binding.get("equality"))
.and_then(|equality| equality.get("type"))
.context("Future bound is missing its Output equality")
}

fn extract_async_trait_future_output(output: &serde_json::Value) -> Option<&serde_json::Value> {
let pin = output.get("resolved_path")?;
if resolved_path_leaf(pin) != Some("Pin") {
return None;
}
let boxed = generic_type_arg(pin, 0)?.get("resolved_path")?;
if resolved_path_leaf(boxed) != Some("Box") {
return None;
}
let dyn_trait = generic_type_arg(boxed, 0)?.get("dyn_trait")?;
let traits = dyn_trait.get("traits")?.as_array()?;
traits
.iter()
.filter_map(|entry| entry.get("trait"))
.find(|trait_| resolved_path_leaf(trait_) == Some("Future"))?
.get("args")?
.get("angle_bracketed")?
.get("constraints")?
.as_array()?
.iter()
.find(|constraint| constraint.get("name").and_then(|name| name.as_str()) == Some("Output"))?
.get("binding")?
.get("equality")?
.get("type")
}

fn resolved_path_leaf(resolved: &serde_json::Value) -> Option<&str> {
let path = resolved.get("path")?.as_str()?;
Some(path_suffix(path))
}

fn generic_type_arg(resolved: &serde_json::Value, index: usize) -> Option<&serde_json::Value> {
resolved
.get("args")?
.get("angle_bracketed")?
.get("args")?
.as_array()?
.iter()
.filter_map(|entry| entry.get("type"))
.nth(index)
}

fn is_result_subscription_return(output: &serde_json::Value) -> bool {
if !is_result_return(output) {
return false;
Expand Down Expand Up @@ -1398,4 +1485,122 @@ mod tests {
"unexpected error: {err}"
);
}

#[test]
fn unwraps_send_future_output() {
let output = serde_json::json!({
"impl_trait": [
{
"trait_bound": {
"trait": {
"path": "core::future::Future",
"args": {
"angle_bracketed": {
"args": [],
"constraints": [
{
"name": "Output",
"binding": {
"equality": {
"type": {
"resolved_path": {
"path": "Result",
"id": 1,
"args": null
}
}
}
}
}
]
}
}
}
}
},
{
"trait_bound": {
"trait": {
"path": "Send",
"id": 2,
"args": null
}
}
}
]
});

let unwrapped = unwrap_future_output(&output).expect("future output");

assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result"));
}

#[test]
fn unwraps_async_trait_send_future_output() {
let output = serde_json::json!({
"resolved_path": {
"path": "::core::pin::Pin",
"args": {
"angle_bracketed": {
"args": [{
"type": {
"resolved_path": {
"path": "Box",
"args": {
"angle_bracketed": {
"args": [{
"type": {
"dyn_trait": {
"traits": [
{
"trait": {
"path": "::core::future::Future",
"args": {
"angle_bracketed": {
"args": [],
"constraints": [{
"name": "Output",
"binding": {
"equality": {
"type": {
"resolved_path": {
"path": "Result",
"id": 1,
"args": null
}
}
}
}
}]
}
}
}
},
{
"trait": {
"path": "::core::marker::Send",
"args": null
}
}
],
"lifetime": "'async_trait"
}
}
}],
"constraints": []
}
}
}
}
}],
"constraints": []
}
}
}
});

let unwrapped = unwrap_future_output(&output).expect("async-trait future output");

assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result"));
}
}
23 changes: 12 additions & 11 deletions rust/crates/truapi-server/src/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,28 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;

use futures::future::LocalBoxFuture;
use futures::future::BoxFuture;
use tracing::instrument;

use crate::frame::{Payload, ProtocolMessage};
use crate::generated::wire_table::{RequestFrameIds, SubscriptionFrameIds};
use crate::subscription::{Spawner, SubscriptionManager, SubscriptionStream};
use crate::transport::Transport;

/// A handler for a request-response method. The returned future is not
/// required to be `Send` because the truapi trait uses `async fn`, whose
/// auto-Send-ness is not guaranteed. The `request_id` is the per-frame
/// identifier; handlers thread it into the `CallContext` so trait methods
/// can correlate logs/cancellation with the originating request. On the
/// error path handlers return the complete SCALE-encoded response payload.
/// A handler for a request-response method. TrUAPI service traits require
/// their returned futures to be [`Send`], allowing native dispatch to move
/// across executor threads while WASM remains free to poll the same future on
/// its local executor. The `request_id` is the per-frame identifier; handlers
/// thread it into the `CallContext` so trait methods can correlate
/// logs/cancellation with the originating request. On the error path handlers
/// return the complete SCALE-encoded response payload.
pub type RequestHandler =
Arc<dyn Fn(String, Vec<u8>) -> LocalBoxFuture<'static, Result<Vec<u8>, Vec<u8>>> + Send + Sync>;
Arc<dyn Fn(String, Vec<u8>) -> BoxFuture<'static, Result<Vec<u8>, Vec<u8>>> + Send + Sync>;

/// A handler for a subscription method. On the error path the handler returns
/// the complete SCALE-encoded `_interrupt` payload.
pub type SubscriptionHandler = Arc<
dyn Fn(String, Vec<u8>) -> LocalBoxFuture<'static, Result<SubscriptionStream, Vec<u8>>>
dyn Fn(String, Vec<u8>) -> BoxFuture<'static, Result<SubscriptionStream, Vec<u8>>>
+ Send
+ Sync,
>;
Expand Down Expand Up @@ -72,7 +73,7 @@ impl Dispatcher {
/// since each request id must own exactly one handler.
pub fn on_request<F>(&mut self, ids: RequestFrameIds, handler: F) -> Option<RequestEntry>
where
F: Fn(String, Vec<u8>) -> LocalBoxFuture<'static, Result<Vec<u8>, Vec<u8>>>
F: Fn(String, Vec<u8>) -> BoxFuture<'static, Result<Vec<u8>, Vec<u8>>>
+ Send
+ Sync
+ 'static,
Expand All @@ -95,7 +96,7 @@ impl Dispatcher {
handler: F,
) -> Option<SubscriptionEntry>
where
F: Fn(String, Vec<u8>) -> LocalBoxFuture<'static, Result<SubscriptionStream, Vec<u8>>>
F: Fn(String, Vec<u8>) -> BoxFuture<'static, Result<SubscriptionStream, Vec<u8>>>
+ Send
+ Sync
+ 'static,
Expand Down
19 changes: 19 additions & 0 deletions rust/crates/truapi-server/src/host_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,25 @@ mod tests {
}
}

fn assert_send<T: Send>(_: T) {}

fn assert_send_sync<T: Send + Sync>() {}

#[test]
fn product_runtime_and_dispatch_future_are_send() {
assert_send_sync::<ProductRuntime>();
let (host_config, product) = runtime_config("myapp.dot");
let runtime = ProductRuntime::from_platform_with_config(
Arc::new(StubPlatform::default()),
host_config,
product,
test_spawner(),
Arc::new(RecordingSink::default()),
);

assert_send(runtime.receive_frame(Vec::new()));
}

#[test]
fn dispose_cancels_active_subscriptions() {
let theme_stream_dropped = Arc::new(AtomicBool::new(false));
Expand Down
Loading