Closed cynecx closed 11 months ago
Thanks for the report! I should've known there was something I missed in https://github.com/smol-rs/event-listener/pull/94.
In the short term this can be fixed by checking the Event
being passed into listen()
and making sure to replace it properly. Unfortunately it looks like #94 introduced more footguns than it fixed, in addition to the performance loss. I'd like to revert this, but I think 3 breaking changes in six months is a little much for a crate that's supposed to be stable.
@smol-rs/admins I won't have time to fix this until the weekend, any thoughts here?
In my opinion, the general api design of
Event/EventListener
is rather unnecessarily complex and introduces more overhead than needed. Tokio'sNotify
api is much "better" in that regard. 🤷♂️
Ours was simple and straight forward too before 3.0.
In the short term this can be fixed by checking the
Event
being passed intolisten()
and making sure to replace it properly. Unfortunately it looks like #94 introduced more footguns than it fixed, in addition to the performance loss. I'd like to revert this, but I think 3 breaking changes in six months is a little much for a crate that's supposed to be stable.
True. I wonder if we should revert back to 2.x API. That API was quite stable and lacked all these footguns and unsoundness behaviors. I'm fine with some allocations if that's the cost.
The 2.X API, while admittedly simpler, pretty recklessly abused heap allocations. The fact that the 3.0 API doubled its speed should be pretty good evidence of this. Therefore I would oppose going back to the 2.X API.
There is very little overhead in the 3.X API. Some overhead was introduced in #94, but I only ever measured it as a 7% performance drop. The no_std
API has a higher overhead, but that's the tradeoff for embedded system support and a lack of clean mutexes.
Although I do think smol
should prioritize simplicity over performance, a 50% time reduction is pretty hard to say no to. Not to mention, the simple API still exists. You can just call event.listen()
if you don't mind the heap allocation and just use event-listener
almost exactly the same as in 2.X. Restricting the API to something that performs worse in 100% of cases because some users might become confused isn't what I want for this crate.
Tokio's
Notify
api is much "better" in that regard. 🤷♂️
I'd like to clarify that tokio
's Notify
API uses something very similar to what event-listener
v3.0 used. The only real difference is that polling Notified
automatically inserted the listener into the list, as opposed to listen()
needing to be called to manually insert the listener. In my opinion this is a footgun that can cause deadlocks in certain circumstances.
In my opinion this is a footgun that can cause deadlocks in certain circumstances.
Please elaborate on that.
In general, it appears a bit weird to me that Listener
can be in a "completely empty" state at all. It does not directly seem particularly useful to me; at least not more useful than it without the Option
(s?) inside, and being wrapped by a (potentially pinned) Option
. It might make more sense to provide an API which gets passed a closure (the closure should be receiving a pinned, listening Listener
, but such that the Pin
can't escape (e.g. due to being bound by a forall lifetime)), and one which allows the user to provide its own wrapping
method to wrap it such that it is pinned, but the wrapper being movable (that is, Unpin + DerefMut<Target = EventListener>
). It might be useful to provide some utility functions to deal with swapping out Listeners in Options, etc., but normally that should be handled by Drop
and similar traits. (As this seems like over-adaption to a use case which I haven't encountered much at all in the wild, although I might be wrong about that usage distribution)
Please elaborate on that.
I elaborate more in this comment.
In general, it appears a bit weird to me that
Listener
can be in a "completely empty" state at all. It does not directly seem particularly useful to me; at least not more useful than it without theOption
(s?) inside, and being wrapped by a (potentially pinned)Option
.
Unfortunately we can't create an already-pinned EventListener
, unless you want to add a macro that uses a private constructor to create it. That's one way of doing it, I suppose.
It might make more sense to provide an API which gets passed a closure (the closure should be receiving a pinned, listening
Listener
, but such that thePin
can't escape (e.g. due to being bound by a forall lifetime))
Closures can't really be used in async
functions.
Closures can't really be used in
async
functions
why? I'd understand if that were about closures returning Future
s or such, but why can't "normal" closures, or simple functions, e.g. Box::pin
or such be used?
Although I do think
smol
should prioritize simplicity over performance
Sure but as a Rust crate, it should priorities soundness, safety and correctness above all IMO.
a 50% time reduction is pretty hard to say no to.
In general, yes but it depends on two other factors: what was the performance like w/o this reduction (i-e the actual gain) and the price to pay for this. If the price is having easy footguns and strange API (as @fogti also pointed out), then we have to be critical of the performance gain being worth it.
Unfortunately we can't create an already-pinned
EventListener
, unless you want to add a macro that uses a private constructor to create it. That's one way of doing it, I suppose.
I think that would be a lot better than having a footgun.
Yes, all of the APIs proposed have problems. The 2.x API had serious performance implications and the 3.x API had the footgun. If we're considering another API break, I'd rather use one that takes advantage of Rust's type system. Maybe:
EventListener
trait.HeapListener
implementation that uses the heap.StackSlot
structure that sits on the stack.Pin<&mut StackSlot>
can be combined with an &Event
to create a StackListener
which also implements the EventListener
trait. Combining inserts the slot into the listener list.Regarding the issue, #101 is the short term fix for this problem
We have an
EventListener
trait.We have a
HeapListener
implementation that uses the heap.We have a
StackSlot
structure that sits on the stack.
Pin<&mut StackSlot>
can be combined with an&Event
to create aStackListener
which also implements theEventListener
trait. Combining inserts the slot into the listener list.
That sounds like a great idea but how about the new trait is called Listener
and we name HeapListener
as just EventListener
. Since we have had a few API breaks in a very short amount of time, I think a lot of people would be porting directly from 2.x to 5.x and it'd be good to make it easy for them to port.
Also a macro might be good for handling the StackSlot
creation.
Published the fix for this in v4.0.1
event-listener
in its current state (https://github.com/smol-rs/event-listener/commit/531c106f0ee27bf3fa7cf1bb0a621e46a1a4c9ed) is unsound. It's possible to trigger a use-after-free bug completely with safe Rust.PoC:
cargo miri run
:The issue is that
EventListener::listen(mut self: Pin<&mut Self>, event: &Event<T>)
doesn't deal with the case when theEventListener
is already currently linked/associated with another/or sameEvent
. It just unconditionally overwrites the content of the EventListener'sListener
.In my opinion, the general api design of
Event/EventListener
is rather unnecessarily complex and introduces more overhead than needed. Tokio'sNotify
api is much "better" in that regard. 🤷♂️