-
Notifications
You must be signed in to change notification settings - Fork 32
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
Rust ws server asset protocol #229
Rust ws server asset protocol #229
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the advantage of the AssetResponder over the approach used by services to return Bytes? I like this approach, but it seems like a new pattern for a user to learn.
I'm also still trying to understand how this will work if the user's implementation needs to do some slow work to get the asset. With python specifically, I have a similar question with services (#230) — it seems like we want to have some sort of out-of-band delivery of responses (services or assets) that correlate by call_id/request_id. Maybe this isn't a use case we need to support yet, though.
} | ||
|
||
/// Send a successful response to the client with the asset. | ||
pub fn send_data(&self, asset: &[u8]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this is sync, how does it play with longer-running responses? Can I respond with assets downloaded from s3, for example?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can download the asset in a background thread or in a new tokio task, and then call this when it's fully buffered.
You mean take a Result with a single respond method instead of one each for success and failure? Yeah I can align that.
ServerListener has a note: These methods are invoked from the client's main poll loop and must not block. If blocking or The only callbacks that return a value are on_get_parameters and on_set_parameters which are expected to be quick. Service calls and asset responders use this responder object pattern where you can launch the work in a thread pool, or an async runtime, and then call respond() when the result is ready. For cheap handlers this works well, as it does for on_get_parameters/on_set_parameters where we need the result right away. For expensive handlers it means the user has to do some kind of async or threads themselves. Is that better than doing it for them by calling the handlers from the blocking thread pool to begin with? |
The service handler has a
In my opinion, right now we should focus on examples targeting the Foxglove app as the client, rather than building out custom clients. e.g., how could this be used to load a URDF into the 3d panel for live viz? |
That'd be an anti-pattern with the fetch asset API. You're not supposed to block for long, so if you returned it as a Result we'd need to call that via spawn_blocking and document that as an exception from the other handler methods. But maybe that's better than requiring the fetch asset handler to take responsibility for doing its work in the background. For async you'd need a different interface (ideally an async handler). I can't think of a nice way to structure that code via the ServerListener. It works for services because they don't use that interface. We could do a similar dedicated API for fetch asset with a similar interface. I can rewrite it like that if it makes sense.
That makes sense. I don't know how to do that in the app, I'll look into it. |
rust/foxglove/src/websocket.rs
Outdated
// Invoke the handler. | ||
service.call(Client(self), request, responder); | ||
service.call(Client::new(self), request, responder); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should also rework the service Responder
to take a Client
instead of Arc<ConnectedClient>
, I suppose. That can be a separate change.
Yeah, might be nice to use the same pattern as the service
Maybe? A big motivation for doing services differently was to accommodate multiple unrelated service implementations, without forcing the user to build a routing layer. We don't have that same motivation here, since there's at most one implementation for fetching assets. If we were to offer a separate The bigger issue is about blocking the client's poll loop, which is an awfully subtle footgun. Maybe the |
Yeah these are exactly the kinds of considerations I've been thinking about. I think ideally we take a sync or async callback somehow and then invoke it ourselves via spawn_blocking or spawn, and document that in the API. So no footgun, and the user can write their code according to whatever works best (e.g. sync for filesystem or Python, or async for a rust network operation), and we can "cancel" it when the client disconnects. I think that isn't easily done via the ServerListener, we'd need something like: ServerBuilder::new()
.fetch_asset_handler(sync_callback)`
// Or
.fetch_asset_async_handler(async_callback) Which can be implemented something like: use std::future::Future;
use tokio::task::{spawn, spawn_blocking};
/// Define an enum to distinguish between sync and async callbacks
enum Callback<ResultType> {
Sync(fn() -> Result<ResultType, String>),
Async(fn() -> impl Future<Output = Result<ResultType, String>> + Send + 'static),
}
impl<ResultType: Send + 'static> Callback<ResultType> {
/// Execute the callback correctly based on its type
async fn execute(self) -> Result<ResultType, String> {
match self {
Self::Sync(sync_fn) => {
// Run sync function in a blocking thread
spawn_blocking(move || sync_fn()).await.unwrap_or_else(|e| Err(format!("Task join error: {}", e)))
}
Self::Async(async_fn) => {
// Run async function in an async task
spawn(async_fn()).await.unwrap_or_else(|e| Err(format!("Task join error: {}", e)))
}
}
}
}
#[tokio::main]
async fn main() {
// Define a synchronous callback
let sync_callback = Callback::Sync(|| {
println!("Sync callback running");
Ok(24)
});
// Define an asynchronous callback
let async_callback = Callback::Async(|| async {
println!("Async callback running");
Ok(42)
});
// Execute both callbacks
let res1 = sync_callback.execute().await;
println!("Sync callback result: {:?}", res1);
let res2 = async_callback.execute().await;
println!("Async callback result: {:?}", res2);
} |
As we discussed this morning, I think we should try to keep it simple, and lean towards async, since that's what we expect rust devs to be doing anyway.
I don't think it's possible to describe fn pointers with an We could follow the pattern that trait Call {
type Error;
type Future: Future<Output = Result<(), Self::Error>>;
fn call(&self) -> Self::Future;
}
// Sugaring for an async function or block.
struct CallFn<F, Fut>(F)
where
F: Fn() -> Fut,
Fut: Future<Output = anyhow::Result<()>>;
impl<F, Fut> Call for CallFn<F, Fut>
where
F: Fn() -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
type Error = anyhow::Error;
type Future = Fut;
fn call(&self) -> Self::Future {
self.0()
}
}
// It's dyn-compatible, so long as the function and future are 'static.
impl<F, Fut> CallFn<F, Fut>
where
F: Fn() -> Fut + 'static,
Fut: Future<Output = anyhow::Result<()>> + 'static,
{
fn boxed(self) -> Box<dyn Call<Error = anyhow::Error, Future = Fut>> {
Box::new(self)
}
}
// Examples follow...
async fn handler() -> anyhow::Result<()> {
println!("hello world");
Ok(())
}
fn main() {
let call1 = CallFn(|| async {
println!("hello world");
Ok(())
});
let call2 = CallFn(handler);
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking great!
impl<F> AssetHandler for SyncAssetHandlerFn<F> | ||
where | ||
F: Fn(Client, String) -> FetchAssetResult + Send + Sync + 'static, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be nice to make this generic over the error type, so long as it implements Display
. That would also be consistent with the service handler function wrappers.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's a good idea
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually I tried it and I didn't like this, because it forces the user to specify the error type in the success case, because it can't be inferred. I think it's fine to get the user to call to_string() themselves.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like the case where you have an infallible handler implemented as a closure is going to be pretty rare. Most of the time you're either going to have a function (where the error type is explicitly declared) or the implementation will be fallible (in which case the error type is inferred). Plus, you'll often have more than one place in your handler where you need to return an error. I'd much rather do this:
.fetch_asset_handler_async_fn(|_, url| async move {
reqwest::get(url).await?.bytes().await
})
than this:
.fetch_asset_handler_async_fn(|_, url| async move {
reqwest::get(url)
.await
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())
})
/// Fetch an asset with the given uri and return it via the responder. | ||
/// Fetch should not block, it should call `runtime.spawn` | ||
/// or `runtime.spawn_blocking` to do the actual work. | ||
fn fetch(self: Arc<Self>, _runtime: &Handle, _uri: String, _responder: AssetResponder); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing Arc<Self>
is a little unusual. I wonder whether we might just bury another Arc inside the Fn
structs instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think any implementation of the trait would also need to be in an Arc, and this is the only way to enforce that
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You only need the Arc
if you spawn.
Consider a server that prefetches assets on startup, and serves them up from memory. That could be totally synchronous.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think the callback wrappers should be generic over the error type, and that we don't really need the Arc receiver in the trait. Other than that, LG.
I got rid of the Arc receiver. The user is almost always going to need to use an Arc, because the trait requires them to spawn unless they've got the data already sitting in memory somehow. They're going to need to do a I'm not convinced about the generic Err. I tried it again to see what the error was, but I don't see a nice way around it: If I use Err: Display, then if one implements AssetHandler::fetch and calls respond, it has to look like this: fn fetch(self: Arc<Self>, uri: String, responder: AssetResponder) {
match self.assets.get(&uri) {
Some(asset) => responder.respond(Ok::<_, String>(Bytes::copy_from_slice(asset))),
None => responder.respond(Err(format!("Asset {} not found", uri))),
}
} You have to specify the type of of the unused Error here, which is strange and not intuitive. What do you choose, there is no error! Didn't you run into this as well?
Without that you'd need to return We should make these consistent between the service handler and asset handler, so let's talk about it after you read this and settle on a direction. |
I think |
Borrow some good ideas for wrapping callbacks from @eloff's asset handler work in #229. In particular, I love the idea of handling the `tokio::spawn` and `tokio::task::spawn_blocking` on our side, because it handles the 99% case beautifully. Folks that want even finer control can always implement `Handler` themselves. Changes here: - Remove `handler_fn` (which took `(Request, Responder)` as arguments). - Rename `sync_handler_fn` as `handler_fn`. - Add `blocking_handler_fn` and `async_handler_fn` for blocking and async function handlers. - Remove some useless `'static` bounds.
Oh, that makes sense. I couldn't see it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚢
Co-authored-by: Greg Smith <gasmith@foxglove.dev>
This test was moved into `src/websocket/semaphore.rs` in #229.
This introduces another callback on ServerListener:
Where the AssetResponder has two methods
And a public Client property. Alternately this could be private and we could pass the client as the first argument like the other callbacks. I don't feel strongly about which is better.
I added an example showing how to implement an asset server, although it doesn't show how the client would work. Not sure if I should add a client as well?
I also added a way to send status messages to a specific client on Client, because callbacks in general lack a way to give feedback about errors or warnings.
Which mirrors publish_status on the server handle.