-
Notifications
You must be signed in to change notification settings - Fork 29
Allow for garbage collection of cancellation handlers on long-lived tokens #52
Comments
The important thing to remember is that browsers are free to make any of the sort of unobservable optimizations you suggest. They don't belong in the spec. |
Closing, but will certainly reopen if I misunderstood and the optimizations you suggest require observable semantic changes. |
Yes, please reopen. The additional step I suggested does change the observable behaviour to make the very important garbage collection possible in the first place. function exanple(cancelTime, resolveTime)
return Promise.withCancelToken(new CancelToken(cancel => {
setTimeout(cancel, cancelTime, "over");
}), (resolve, reject) => {
setTimeout(resolve, resolveTime, "done");
return (e => console.log("It's "+e.message));
});
} While
would change. When the token is cancelled after the promise is resolved, the cancel action is no more executed (because there usually is nothing to cancel any more). So instead of logging "It's done" and "It's over" after 1 and 2s respectively, it should only log "It's done". |
OK. I am going to rename this then since it sounds like your issue is not about memory leaks but instead about Promise.withCancelToken sometimes executing its cancelation action twice. |
No, the cancel action is not executed twice. It's just executed after all work is done, when there is nothing to cancel any more. And this really does create a memory leak - try my example and monitor the number of callbacks on |
I see, you just used The number of callbacks on I guess there may be a remaining semantic problem with |
Yeah, one can only hope so. The problem is really that they will fire, they just don't do anything observable. Detecting that is much harder. And no, this is not an edge case. The problem is that cancel actions (the functions installed on |
It's not hard if it's written into the spec that they won't do anything observable. |
Sure, but writing the spec in that way makes it hard to see that they need to be garbage-collected. You didn't realise the importance of this problem immediately, or didn't even think about it in the first place, so it might be doubted that implementors will. Compare this to the (Weak)Map/(Weak)Set section of the spec which talks about the intended garbage collection behaviour extensively. |
Sure, we can try to add an note, although the committee has in the past preferred to not include such things, so my guess is that has a good chance of being removed after committee review. |
It's important to note that the spec doesn't typically specify cleanup or garbage collection anywhere anyway. The term "garbage collection" does not appear in the spec even once and there are in fact (typically older, or run once) implementations of JavaScript that do not collect garbage at all. The spec of WeakMap/WeakSet doesn't mention garbage collection at all - weak maps just have constraints (like no primitive keys and no iteration) that allow implementors to make the map week. While it is obvious that this is the intended behavior the spec does not actually require it anywhere from what I can tell. |
I have a question, @domenic is it intended that For example, Token.race = function(tokens) {
let cancel;
const token = new this(_cancel => cancel = _cancel);
tokens.forEach(token => token.promise.then(cancel));
return token;
}; Token.race([short, long]);
// cancelShort This will leak the handler added to related note from the spec:
|
In general it's anticipated that userland implementations will not be able to accomplish all of the same semantics as built-in ones, yes. |
@domenic although not perfect, I think does seem possible for polyfills to implement the Token.race = function(tokens) {
let cancel;
const token = new this(_cancel => cancel = _cancel);
const detachableReactions = tokens.map(token => {
// something a user-land polyfill could do..
return $$$_attemptThenAndCaptureDetachableReaction(token.promise, cancel);
});
token.promise.then(() => detachableReaction.forEach(r => r.detach());
return token;
}; Kinda icky, and also demonstrates that mixing user-land and native stuff here will also cause leaks. Although icky, in-general a cancellation primitive even with the above semantics would very much be a net positive in apps I have worked on 👍 Question: can we do better? |
Now that is an excellent point why we need Btw, that " |
Ya in an earlier spike I also ended-up with something along the lines of |
Unfortunately @bergus has continued to use this repo as a way to push his proposal, so I'm going to have to moderate this thread. Please keep the discussion focused on this repository. |
Userland libraries don't leak here, only native promises + polyfilled tokens. Even then, it's just latency and not a leak and I don't think it's a big deal since the expensive part (the promises) were taken care of. I don't think this "leak" is a big deal at all. |
@benjamingr Even userland libraries need to go to great lengths not to leak here, I've not yet seen a token implementation that doesn't. Btw, with the semantics I described in the OP there would be a way to detach reactions from the token promise, it's just a bit convoluted: Token.race = function(tokens) {
const {token, cancel} = this.source();
function onCancelled(e) {
for (const detach of detachers)
detach();
cancel(e);
}
const detachers = tokens.map(t => {
let resolve;
Promise.withCancelToken(t, res => { // this subscribes `onCancelled` on `t`
resolve = res; // and drops it when `resolve` is called
return onCancelled;
});
return resolve;
});
return token;
}; However, this only solves the case where the short-lived tokens are cancelled regularly without the long-lived one being affected. It does still leak when the |
can you explain this in more detail? |
@domenic you got me thinking, if we had the concept of a It is good that a solution to the leak I am concerned about is possible for native implementations at-least. As it stands it still doesn't seem right, but I'll noodle on it more and try to come up with something concrete.
@benjamingr I don't believe this to be true, as todays user-land promises don't have a weak reference to its enqueued fulfillment reaction value's (the success handlers). The spec in this proposal aims to address this by recommending implementations "weakly" retain these. This mitigates the handler leak problem, but I don't believe can be implemented in all cases in user-land. |
@stefanpenner I had that in mind as well, but I don't think a |
@bergus could you provide a quick concrete example where the |
function example(act) {
const {token, cancel} = CancelToken.source();
token.promise.then(act); // some side effect (strongly referenced)
const a = CancelToken.source(),
b = CancelToken.source();
a.token.promise.then(cancel); // strong reference from `a.token` to `cancel` (and `token` and `act`)
b.token.subscribeWeak(cancel); // weak reference from `b.token` to `cancel` (etc)
return {cancelA: a.cancel, cancelB: b.cancel}; // notice the cancel function reference the respective tokens
} Now the following would work well ({cancelA, cancelB} = example(()=>console.log("executed"))); // make them global variables for simplicity
// later
cancelA() // will cause the log
cancelA = null; // destroy, now `a.token`, `cancel` and `token` can be collected
// notice how the latter two were only weakly referenced by `cancelB`
// later
cancelB(); // a no-op now but the following would go haywire: ({cancelA, cancelB} = example(()=>console.log("maybe")));
// later
cancelA = null; // destroy, now `a.token`, `cancel`, `token` and `act` can be collected
// notice how the latter were only weakly referenced by `cancelB`
// later
// in the meantime, the garbage collector might have run, or not
cancelB(); // *should* cause the log, right? But maybe `act` is already destroyed In general, we cannot weakly reference event handlers, as the event source is often the only object holding a reference to them, so it could as well drop them right away. |
I'm not sure that the change to It seems to me that providing some way to deregister a cancellation listener is a design requirement. Thoughts? |
Yes, that's exactly the problem. The fix to I agree on deregistering being a design requirement, I guess this should be stated more clearly (and the proposal be re-evaluated in light of that). It remains an open question whether this deregistration should be used internally ( |
@stefanpenner raised this (I guess) in the July meeting but it was deferred.
I'll use the
xhrAdapted
anddelay
function from the docs. Let's assume the following code:With the current proposal, this code will leak memory like crazy. On every iteration of the loop, no less than 4 callbacks are registered on
token.promise
, and they are never garbage-collected untilcancel
is called (which might be never) or the token is collected itself. Two of the callbacks are closing overxhr
andid
respectively, keeping all of the created request and timer objects as well.If the promisifications of XHR and timeout would set all closure variables to
null
explicitly after calledresolve
/reject
, and doing nothing in the token callback when they are tested to benull
, the memory could be theoretically released. However, this is a rather fragile approach, it's error-prone (easy to forget), and to collect the 4 callbacks when they are proven to do nothing any more would require heavy (and afaik yet unknown) engine sophistication.For this to work out, we need a way unsubscribe callbacks, from tokens at least and possibly from promises in general. Let's not be too radical and keep it limited to the former.
Imo it is imperative that
Promise.
will unsubscribe the callback from the token (so that it no longer is called on a cancellation request) when the resulting promise is resolved. Whether that should possibly be extended until the promise is settled, not onlycancellablewithCancelTokenresolve
/reject
have been called, remains to be discussed.I am not sure to what extent the spec should cover this garbage collection. A very simple fix would be to insert a new step
between steps 2 and 3 in §1.3.1.1
Promise.withCancelToken
Cancelation Reaction Functions. Then we'd leave it to the engines to figure out when callbacks are supposed to be collectable.A better idea would be to explicitly remove the callback from the [[CancelTokenPromise]].[[PromiseFulfillReactions]] to make the intent clear. I would model that by adding the
PromiseReaction
that is registered on the cancellation token to the [[AlreadyResolved]] record of the promise resolving functions (via CreateResolvingFunctions), and then in those function after the [[Value]] test set not only the [[Value]] tofalse
, but also the [[AlreadyResolved]].[[CancelationReaction]].[[Capability]] to empty and the [[AlreadyResolved]].[[CancelationReaction]].[[Handler]] toundefined
.The text was updated successfully, but these errors were encountered: