-
Notifications
You must be signed in to change notification settings - Fork 178
Use more precise fn types for task/worker #1032
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
base: main
Are you sure you want to change the base?
Conversation
|
An alternative to |
DJMcNab
left a comment
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 strongly agree with the change to the signatures of the 'non-raw' functions; they make sense.
However, I don't think the change to the raw version is justifiable. You are correct that these are a weak point in Xilem's current design; something does need to give. I've been exploring some ideas in https://github.com/DJMcNab/xilem/tree/xilem-renaissance, but I don't have time to push this forward myself.
| pub fn task<M, H, State, Action, Fut>( | ||
| init_future: fn(MessageProxy<M>) -> Fut, | ||
| on_event: H, | ||
| ) -> Task<fn(MessageProxy<M>) -> Fut, H, M> | ||
| where | ||
| F: Fn(MessageProxy<M>) -> Fut, | ||
| Fut: Future<Output = ()> + Send + 'static, | ||
| H: Fn(&mut State, M) -> Action + 'static, | ||
| M: AnyMessage + '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.
Naïvely, this signature change seems pretty reasonable - making the lack of support for capturing variables here be a check provided by the compiler is nice.
I am slightly worried that this now allows you to change what the actual function implementation is at runtime; that is effectively the same footgun as this. However, doing that accidentally would be sufficiently harder that I don't think we need to warn about it.
xilem/src/view/task.rs
Outdated
|
|
||
| pub struct Task<F, H, M> { | ||
| init_future: F, | ||
| init_future: Mutex<Option<F>>, |
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's not wrong that this API is nicer to use, but I'm also not sure it's entirely correct.
In particular, it breaks for cases where you are stashing a view (for example, in an Arc). It's true that you could internally use a Mutex, which would break in the same way, but the user can then decide how to handle the failure condition, rather than unconditionally panicking.
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.
Yes, I had not considered stashing a view and later re-inserting it into the tree. It's not entirely clear to me what the expected semantics for that scenario should be, i.e. should the task be run the first time the view is inserted in the tree, or each time the view is inserted in the tree after being removed.
I think in most cases for worker it would make the most sense for the future to be spawned each time the view is inserted. For task I think it is less clear, and it might be good to support both options. Perhaps something like this:
pub fn task_raw_once<M, F, H, State, Action, Fut>(init_future: F, on_event: H) -> Task<F, H, M>
where
F: Fn(MessageProxy<M>) -> Fut,
F: FnOnce(MessageProxy<M>) -> Fut,
Fut: Future<Output = ()> + Send + 'static,
H: Fn(&mut State, M) -> Action + 'static,
M: AnyMessage + 'static,
{
let init_future = Mutex::new(Some(init_future));
task_raw(move |proxy| {
let init_future = init_future.lock().unwrap().take();
async move {
if let Some(init_future) = init_future {
init_future(proxy).await
}
}
}, on_event)
}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.
Yes - if we're able to provide a helper for the Mutex based solution, that seems to be the best of all worlds.
xilem/src/view/task.rs
Outdated
|
|
||
| fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { | ||
| let init_future = self.init_future.lock().unwrap().take(); | ||
| let init_future = init_future.unwrap(); |
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.
In particular, it's now possible to write code which triggers this unwrap, which is not something I particularly want.
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 completely agree. I had not considered the stashing view scenario could allow build to be called more than once.
Use function pointers for `run_once()`, `task()`, and `worker()` to ensure there are no captures. Add `task_raw_once()` that takes an `FnOnce` function as `init_future` for better ergonomics with views that will not be stashed.
DJMcNab
left a comment
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.
Thanks! It's nice to make this a little bit nicer.
This is landable as-is; let me know if you want to tweak/not tweak the docs further.
| /// This task will only be run the first time this view is inserted in the tree. If the | ||
| /// view is stashed and then later re-inserted into the tree, the task will not be run again. | ||
| /// However, if the view is re-created and then inserted into the tree the task will be run again. |
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.
Perhaps we can also say something like the "task will only be launched for each returned view value". That is, the boundary of "the task will be run again" is calls to this function. In my mind documentation for components normally expects the component to be called every frame.
I don't have a very succinct answer here.
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, it's a fairly confusing concept. I don't have any better ideas for documentation at the moment.
|
I can rebase onto main if you want. I think that the ergonomics of being able to use FnOnce are still important, but perhaps a different overall design for the I think that with #1079, the |
Yeah, I think that change to |
|
I have a common pattern in my app where I match on some part of my let Some(foo) = state.foo {
OneOf::A(fork(
label("Doing task..."),
task_raw_once(
async move |proxy| {
let _ = proxy.message(do_something(foo).await);
},
|data: &mut State, msg| {
...
},
),
))
} else {
...
}A similar version is where I have a component that I pass some data that has been extracted from State. Where that data comes from can vary a lot between different calls to the component. pub fn save<State, Fut>(fut: Fut) -> impl WidgetView<State, AppAction> + use<State, Fut>
where
State: 'static,
Fut: Future<Output = Result<(), String>> + Send + 'static,
{
fork(
label("Saving..."),
task_raw_once(
async move |proxy| {
let _ = proxy.message(fut.await);
},
|_data: &mut State, msg| match msg {
...
},
),
)
}If task is changed to take a What I like less is having to repeat the extraction of needed state for the task. For example, the first example become would probably look like: task(
|state, proxy| {
let foo = state.foo.unwrap();
async move {
...
}
},
...,
)Which is both more verbose and feels less "safe" because I have to unwrap the option inside the closure or else deal with a possible The second example would probably need to be restructured something like this: trait Savable {
async fn save(&mut self) -> Result<(), String>;
}
pub fn save<State>(state &mut State) -> impl WidgetView<State, AppAction> + use<State>
where
State: Savable + 'static,
{
fork(
label("Saving..."),
task_raw_once(
|state, proxy| {
let fut = state.save();
async move {
let _ = proxy.message(fut.await);
}
},
|_data: &mut State, msg| match msg {
...
},
),
)
}The implementation of The main thing I like about capturing values from the environment for tasks is not having to do the work to extract those values twice and not having to worry about whether the state has changed before the closure executes. I do see the advantages of keeping the closures stateless, and passing in state as an argument makes it possible to work with fully stateless closures which was not possible before. It probably makes the most sense for xilem to go the route of stateless tasks, in which case I'd probably argue for removing |
|
I'm finding this quite hard to reason about at the moment. Broadly (at least in theory), this is a use case for
Fwiw, I wasn't aware that async closures worked here at all, because we wrote a lot of this initial code before they were even supported. I think it might be helpful for us to meet to discuss what you're developing, as this seems to be the most significant user of Xilem at the moment. My calendar availability is at https://calendar.app.google/y4Ypr9Hby2KJWapZ7, or reach out on Zulip if none of those times work (and you do want to chat). For example, have you seen #xilem > Environment System? |
Changes the bounds on the
init_futurefunction inTaskandWorkerto beFnOnceinstead ofFn. This ensures that the function can only be called once at the type system level, and allows the closure to move out of captured values which can avoid unneeded clones.The bounds on the
task()andworker()functions are changed to takefn()types instead of genericFnOnceto allow the compiler to enforce that they do not capture any values from their scope. If you would prefer to keep the const assert instead, they can be changed to takeFnOncetypes liketask_raw()andworker_raw().This does require introducing a
Mutexinto theTaskandWorkertypes to allow theinit_futurevalue to be moved out inbuild(), sincebuild()takes a shared reference to self. This will increase the memory footprint of the view types slightly, but should have minimal performance impact since the mutex should never have any contention.This approach does not work for
xilem_core::RunOncebecauseMutexis only available instd.