-
Notifications
You must be signed in to change notification settings - Fork 178
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
Fix Promise memory leak #127
Conversation
What if someone actually wants the previous behavior? In JavaScript it's pretty common to do a fire-and-forget style promises which this will make harder to do (you'll have to manually save the Wouldn't it make more sense sense to have the previous behavior as default and then allow the user to opt-in to this one? For example like this: promise.done( |...| ... ); // This will stay alive until the promise is triggered.
let handle = promise.done( |...| ... ).cancel_on_drop(); // This will cancel it when `handle` is dropped. (or have two methods for this) |
No problem, just use It would be weird to have inconsistent behavior between |
If we want to make it easy to have fire-and-forget things (like event listeners, promises, mutation observers, etc.) then I think the cleanest way is to create a new let handle = promise.done(|...| ...);
handle.forget(); The reason for having a |
And you can't use The point about consistency is a good one though. I think that the behavior of not canceling on I can envision three use cases:
Perhaps we should work out a convention for this? E.g. something like this (names are up for discussion): pub trait Handle {
type AutoCancelHandle;
fn cancel_now(self);
fn auto_cancel(self) -> Self::AutoCancelHandle;
} So then: element.add_event_listener( ... ); // Will never be dropped.
let handle = element.add_event_listener( ... ).auto_cancel(); // Will be cancelled on normal out-of-scope `Drop`.
let handle = element.add_event_listener( ... );
...
handle.cancel_now(); |
That's not good, because that means it permanently leaks memory, and it's very easy to do that accidentally.
I think that's a bad argument: we don't want to manually drop listeners, we must manually drop listeners because of technical limitations. If we could figure out a way to automatically drop listeners then I would suggest we use it. Also, I don't think it's good to use implementation details to justify a public API.
We shouldn't make leaking memory the default behavior, especially since it's very easy to do it accidentally. That goes against Rust's overall philosophy and API design. Rust has a very clear memory model, which drastically helps to prevent memory leaks, I think we should embrace that model.
I don't think we can copy JavaScript in this situation. In JavaScript, if you add an event listener to a DOM object, you don't need to worry about removing the event listener, because the event listener will automatically be removed when the DOM object is removed from the DOM (and then the callback will be garbage collected). That isn't the case with Rust. Even after the DOM object is removed from the DOM, even after the DOM object has been garbage collected, the Rust callback will still be consuming memory. There's just no way to avoid manual memory management, and the most convenient and Rusty way of doing manual memory management is to use the Rust idiom of tying memory to scopes, which prevents accidental memory leaks.
Yes, I agree we need a uniform way of handling this, I just disagree that leaking memory should be the default. Users should need to opt-in to memory leaks. |
Well, both approaches have its pros and cons. For example, with I agree that preventing memory leaks is important, but that's also something that doesn't come for free. Now, I'm not saying that auto-drop-by-default is a bad idea per-se - you do make some very good points, but the situation is not purely black and white. (I'm not fully convinced myself, one way or the other.) |
My plan is to add
That won't happen, I specifically tested that and made sure that it won't throw. That's handled by the
Yes, but at least the programmer knows that something is wrong, so they try to figure out why it's not working, and then they fix it. With leak-by-default the programmer doesn't even know that something is wrong, it's completely silent, so the problem never gets fixed. Also, that issue is mostly solved by On other issues that we disagree on, I don't have strong opinions, but I do have strong opinions about this. So I'll try to clarify my thinking as much as possible. JavaScript programmers don't worry about memory leaks (because of the garbage collector). Rust programmers don't worry about memory leaks (because of RAII). They have very different ways of managing memory, but in both languages the memory is automatically managed. In other words, the default is to not leak memory. This gives a huge amount of peace-of-mind to the programmer, and is very important. If we leak memory by default in stdweb, this is contrary to the intuition and expectations of both Rust and JavaScript programmers:
The end result is that leaking memory by default will cause very subtle and hard to find bugs, so stdweb will get a reputation for being unreliable, buggy, and causing memory bloat. Or at the very least it will get a reputation for being hard-to-use and easy-to-screw-up. Programmers of both Rust and JavaScript do not expect silent-accidental-permanent-memory-leaks-by-default-with-no-warning. Requiring the programmer to be careful and use discipline to avoid memory leaks isn't a good solution. I think that leaking by default is contrary to both the JavaScript and Rust principles, and it's also contrary to stdweb's principles (which is to provide safe Rusty APIs for JavaScript). Yes it is true that Given all of the above, my proposed solution is the following:
The end result is the following: // This gives a warning because of #[must_use].
//
// If the programmer ignores the warning, then it won't
// leak memory, but it probably won't behave how the
// programmer wants, which causes the programmer
// to investigate why it isn't working, and then fix it.
element.add_event_listener(...);
// This makes it clear that it is *intentionally* leaking
// memory, with all the pros and cons of doing so.
element.add_event_listener(...).leak();
// This makes it clear that the handle is now block-scoped
// and will be cleaned up when the scope ends.
let handle = element.add_event_listener(...);
// We can use normal Rust idioms to drop early.
drop(handle); Let's compare that with the leak-by-default model: // No warning whatsoever that it is leaking memory. The
// programmer doesn't even realize anything is wrong, so
// it doesn't get fixed.
//
// In addition, there is no way of making it clear to the compiler
// or other programmers that it is intentionally leaking: it might
// be leaking intentionally, *or* it might be leaking accidentally.
// We have no way of knowing which it is.
element.add_event_listener(...);
// This makes it clear that the handle is now block-scoped
// and will be cleaned up when the scope ends.
let handle = element.add_event_listener(...).auto_drop();
// We can use normal Rust idioms to drop early.
drop(handle); |
It seems it is possible: #![deny(unused_must_use)] But I think that will only affect the current crate, not other crates which are using stdweb. |
Also, I want to clarify one more thing:
I don't think it is the most common. Web apps are constantly creating new DOM nodes and destroying old DOM nodes. When you destroy the old DOM nodes you must cancel the event listeners (to free up memory and avoid a memory leak). Even the If you have a DOM node which lives for the entire duration of your app, and it contains an event listener which also lives for the entire duration of your app, then you don't need to worry about cancelling the event listener. That situation does happen, but in every other situation you do need to worry about cancellation. And I think it is more common to dynamically create/destroy DOM nodes, especially with the recent craze of virtual DOM and SPA. |
P.S. Rather than using #[must_use = "The event listener will be cancelled when the handle is dropped. Use .leak() if you want the event listener to never be cancelled (this will leak memory!)"] In addition to giving a warning, it also explains to the user what the problem is, and one potential way how they can fix it. |
Thanks for the lenghtly explanation! Hmm... yeah, mostly sounds good to me! Although I'm thinking... is there any way we could wrestle back some ergonomy after we'd make the handles cancel-on-drop by default? For example, for event listeners besides the |
@koute I'm not sure if that's possible, since the only way to be notified when a DOM node is removed is to use I think a better approach is to encourage a Virtual DOM style approach, which internally manages everything and automatically frees the callbacks when the DOM nodes are removed. I am currently working on prototyping something like that, I'll show it off soon. |
I think the confusion here is from unclear ownership. When I see “add_event_listener” as a method, I assume that the object is taking ownership of the listener (eg like adding an item to a vector). If destroying the DOM node doesn’t clean up the event listeners for it, I’d be totally oblivious to it until things started breaking. If the handle is actually what represents ownership of the listener, then I agree with the must_use and default drop behavior. I’d also wonder if an alternative style would be better - maybe a rename like create_event_listener or EventListener::for(node). Though the latter just seems too awkward to be discoverable or practical. In general I’d note that if people wanted to write javascript that behaved like JavaScript they’d just use a js! block - the Rust code should behave like Rust code even if there’s some deviation from JavaScript. If naively using it like js would lead to bad things happening, it should be gated in some way. Btw I think “persist” would be better than “leak” or “forget” unless this really is something that people should not be doing. |
@spease In general programmers shouldn't leak memory unless they've carefully considered the benefits, drawbacks, and alternatives of doing so. Using Of course there's nothing stopping people from creating leaks in other situations, but that's their choice. I just want them to know that they are making that choice. |
34dacac
to
f4a9989
Compare
@koute I added in a new After spending a lot of time thinking about this (and trying out different implementations), I think this is the cleanest, simplest, most flexible, and fastest approach. Please take a look and let me know if you think P.S. Since this is a breaking change, it will have to be delayed to version |
Hmm... I like the general design of it! Having a wrapper type which would wrap the original handle certainly feels very clean. Although I think I'd prefer the name As far as another branch goes, hm... that's definitely something to consider, although rebasing that might be a pain. I'll think about it. For now could you split your changes so that we could merge only the trait itself + changes to the |
I'd understand something like this better:
But feel free to ignore this if you feel it's more confusing or if I didn't understand what's going on correctly. |
@spease Thanks for the suggestion, I'm always open for improving the wording to make it easier to understand. However, these parts seem a bit odd to me:
The listener is owned exclusively by the Also, the
Dropping the If it were possible to clean up the listener when the Also, calling
|
4b445d9
to
c1a2e68
Compare
@koute I made your suggested changes, but this isn't ready to merge yet: I still need to add in documentation and unit tests. |
@koute It turned out that I changed this pull request to use the @spease I changed the documentation for |
@Pauan Yes, that makes the ownership situation clear, thanks |
@spease Great, glad to hear it! |
Thanks for all the great work! Hmmm... however, I think we might have jumped the gun here a little with abstracting the trait away to a separate crate. Don't get me wrong - having something like that could be useful in general, however in the context of
And in general ignoring the issues I've mentioned I think this area is still sufficiently underexplored that it's too early to commit to any general-purpose, abstract mechanism. |
But you're not cancelling the Promise (because Promises cannot be cancelled), you're disposing of the memory for the callback. I tried (literally) dozens of names, and I decided on discard because I think it is better than cancel. It's best to think of So you're not cancelling a Timeout, you're dropping a Timeout (which has the indirect side-effect of cancelling the timeout, but that will vary from type to type). In other words, wherever you would implement
What do you mean? The old version also took
I like the name because it suggests "this is dangerous, be careful when using this!" Which is true. Even in the case of Promises, you must carefully guarantee that the Promise will eventually succeed / fail. You can easily guarantee that for Promises you have created, but it is a lot more work to guarantee that for Promises you have not created. So the name is an extra reminder that "this can leak memory! You have to put in extra work to prevent that!" And in the case of event listeners,
Why would that be a problem?
Why is it better to explore this space in the |
I'd say it's the other way around - you're canceling the subscription you've made; disposing of the memory is just a side effect. (: That's how RAII is treated in general - e.g. even in C++ (which is the poster child for RAII-style resource handling) the handles are never purely about memory but about ownership of a given resource which might have nothing to do with memory management at all. I think it's more useful to think in terms of "will this stay alive?" rather than "will this leak?". The memory leak itself is a secondary effect - the leak is there because a given object/subscription is still around.
Again, isn't it the other way around? (: You call
Well, if it were exactly the same as ** - where by "leak" I mean memory which is wasted; e.g. if you have a
But it's incorrect because calling it will not always result in a leak. (: I think that having a good
Sorry, the
For quite a few reasons:
I have nothing against your |
Sure that's how RAII is in general, but with Promises it's not cancelling the subscription, it's only cleaning up memory and nothing else. RAII serves the dual purpose of managing resources and managing memory (which is a specific type of resource), that's why Rust calls it We were discussing the name "discard" vs "cancel", and I said that "it discards the In addition, because there are some types (e.g.
But after the So the only time you can say that it's not leaking is if the programmer wants it to last for the entire duration of their program, which does happen, but it's not the norm.
I don't think it's incorrect, since the overwhelming majority of the time it will leak. And even the mere possibility of leaking is enough justification for the programmer to be more careful. In any case, do you have a suggestion for a better name?
That was intentional. I implemented If I implemented
Interesting, I didn't know that. It's still possible to However, the alternative APIs I tried had other issues, so I still think this is the best API, even with the trait issue. And I would advocate for the same API regardless of whether it's in an external |
Also, to address some more points:
I agree, though stdweb can always re-export
I would be very surprised if stdweb documented that in the That should be documented in the individual types/methods (e.g. the
What parts would you want to make private? The API for the
Yup, that was an intentional choice, since after careful consideration I think the But if a major issue came up I would be fine with making version |
It's not only cleaning up memory, it's also cancelling the subscription you've made. The
But, again, that's not really a memory leak. (: If you call
Again, in my previous post I've enumerated in which cases it will actually leak memory, and if I can count correctly there are more unique cases where it won't leak than it will. (: I understand the need to signal to the user that they have to watch out, but I'd rather not tell them that something will leak, and then in a tiny fine print on the bottom of the page say that I lied and it only leaks in certain cases.
Hmm... well, naming is hard, you know. (:
Yes, I'm perfectly aware of this.
Yes. But I don't care about this in
If there aren't many that cases of when it could actually leak memory - why not? (Something like "for
Right now - none, because as you've said the API is pretty small. But that doesn't mean that we won't want to add something there in the future. |
@Pauan Anyhow, even though we don't have a consensus here I'd like to move this forward and get it merged. We can leave the name bike-shedding for later; for now could you just copy-paste the contents of your |
@koute Sorry, I've been busy with other things. I was planning on replying to you (and also making your suggested changes). Is this time-sensitive? |
c9ad46f
to
84a95e8
Compare
Okay, so I finally had a chance to get this fixed. I created a new However, I did not create a new In practice, what that means is that if users want to create new types and then implement But if they simply want to use the |
Hmmmm... okay, as a compromise that sounds fine for now. I might define my own trait in the future though. (If I'll need to fiddle around with it in an incompatible way, for example.) That said, even if I would do that I'd be open to having an optional non-default feature which would basically do Oh and I reserve the right to rename stuff, although I'll have to think about this further. (Naming stuff is hard.) (: Thanks! |
Earlier I had mentioned creating a virtual DOM library for stdweb, and I have finally published it to crates.io. You can use it like this: html!("div", {
event(|e: ClickEvent| {
...
});
children(&mut [
html!("div", {
style("width", "50px");
style("height", "50px");
style("background-color", "green");
}),
]);
}) It will automatically clean up things like event listeners when the DOM node is removed, and it has a ton of other useful stuff as well. You can read more about it here. |
As explained in the documentation, if the Promise never succeeds / fails then the callback will never be called, and it will leak memory. This pull request fixes that.
Now the
done
method returns aDoneHandle
. When theDoneHandle
is dropped it will drop thecallback
, thus freeing the memory (at least on the Rust side).