-
Notifications
You must be signed in to change notification settings - Fork 0
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
Promise Cancellable Testing #1
Conversation
👇 Click on the image for a new way to code review
Legend |
Still need to do DAG rejection where we want to test local and global side effects. One of the trickiest parts is where there's an intermediate promise. In that area, early rejection can be a problem, because P3 is already rejected. One way to avoid early rejection with P1, P2, and P3 chain. Is to pass a signal handler that doesn't do anything. This ends up causing P3 cancellation to wait on P1, and then leave it to P2 to decide what to do. The alternative is to put a |
I've tested with normal promises and in fact this is the default behaviour of promises if rejection were to occur early: import process from 'process';
process.on('unhandledRejection', (r, p) => {
// @ts-ignore
console.log('UNHANDLED', r.name, r.message);
});
class CustomPromise<T> extends Promise<T> {
public reject;
constructor(executor) {
let reject_;
super((resolve, reject) => {
reject_ = reject;
executor(resolve, reject);
});
this.reject = reject_;
}
}
async function main () {
const p1 = new CustomPromise<string>((resolve) => {
setTimeout(() => {
resolve('p1 result');
}, 100)
});
const p3 = p1.then(() => {
const p2 = new CustomPromise<string>((resolve, reject) => {
setTimeout(() => {
reject(new Error('P2 REJECT'));
}, 100);
});
return p2;
});
const p5 = p3.then(() => {
return new CustomPromise<string>((resolve) => {
resolve('lol');
});
}, (r) => {
console.log('P4 is never produced');
throw r;
});
// @ts-ignore
p3.reject(new Error('P3 REJECT'));
try {
const r = await p5;
console.log(r);
} catch (e) {
console.log(e.name, e.message);
}
}
void main(); The result looks like:
Therefore, we can say that a promise DAG can be:
Where there are "nested" relationships. The arrow here represents a function computation. That is P2 doesn't exist until P1 has resolved. Therefore early rejection of P3 leaves P2 dangling as it is now an unhandled promise rejection. Also consider this:
There there are implicit promises between the then operations, but since the syntax is pointfree style, we can say that:
Such that P3 only exists when P2 resolves, and P2 exists only when P1 resolves. This also means though, you can't directly cancel P3, because it is nested. The true dag is more like:
If P2 and P4 were to exist outside, and only brought in internally, one would argue that their side effects are already in-flight. |
It does appear that Now if P1 is already done by the time P3 is cancelling, then the cancel signal would be ignored by P1 because double rejection is a noop, then the computation to P2 can be informed to also reject. If we want that the default rejection not to cause problems, it seems that we would want:
To be desugared to:
So that it is possible for p2 rejection to occur and just be caught... Let me try this.. |
It actually works. Simply by sticking In fact because the default handler is just to throw the reason. It's simple enough to just do So:
Is enough to allow This does not work if instead we instead did:
Part of the reason is that |
We will need to test this for all the other binders like If At the same time we may have to change to using the species feature so that |
I have found I don't need the |
Furthermore I have found the species is required but it doesn't actually do what I thought it did. It turns out |
In fact, it doesn't change the fact that |
After checking some issues for the See: tc39/ecma262#151 (comment) What's interesting is that |
a08146d
to
41659db
Compare
Ok, the reason why So because So now I'm checking if I can optimise this so it's not producing an additional One thing that is challenging is the fact that because |
So basically all I have to do is to mutate the promise returned by |
It appears that the I can imagine something like:
This would explain why the default cancellation doesn't do anything. Because we cannot associate the signal to anything here, we would have to add the default functionality here. Now we could use call This seems kind of flaky unfortunately. Perhaps this would have worked better if the constructor itself already associate the default signal handler. I just didn't do that originally thinking the signal handler is already custom as part of the executor. But this is making me rethink things. |
The reason it is flaky is that we won't really know which of the static methods vs instance methods are going to be using Now that So far it seems like all the static methods will be using the So we could fix this right now by associating the default signal handler for all static methods, while not doing so for Alternatively IF the default signal handler was done in the constructor. Then if the symbol species was removed, we would end up with all static methods having the signal handler properly done, and all instance methods rely on then, which itself ends up using the constructor as well and having its default signal handler associated too. Then each method just needs to override the signal handler if a custom signal handler is passed in, or override the abort controller if it is passed in. |
We could reserve the usage of We could also make the This still works...
Although if you are supplying a custom signal handler, it's quite likely you do not want to do early rejection of the However how would one do both early rejection AND something else. I guess that's where you can use both the executor and the signal handler.
|
Actually I found that I couldn't do this cleanly because of the case where a On the other hand, the current way with the fix works, but perhaps only for nodejs, if other implementations of promises worked differently this could cause a problem. I'm not sure, will require testing later in the browser to see. |
Another idea, we put a If it is set, it takes precedence. If it is not set, we will default to early rejection. This way early rejection is set by default. One would argue that if you really want a This then unifies it under the constructor, and everything relies on the constructor now. |
Here's an example of using proxies: const abortController = new AbortController();
let signalHandled = false;
const signal = new Proxy(abortController.signal, {
get(target, prop, receiver) {
if (prop === 'addEventListener') {
return function addEventListener(...args) {
signalHandled = true;
return target[prop].apply(this, args);
};
} else {
return Reflect.get(target, prop, receiver);
}
},
set(target, prop, value) {
if (prop === 'onabort') {
signalHandled = true;
}
return Reflect.set(target, prop, value);
}
});
// These 2 do not activate the `signalHandled`
signal.onabort;
signal.addEventListener;
signal.onabort = null;
signal.addEventListener('abort', () => {
});
console.log(signalHandled); This ensures that we know if the |
feff241
to
8b04377
Compare
88fee98
to
0a64714
Compare
This is now done. |
Any further testing will need to occur in production now. |
Note that I switched to using constructors for centralising the default signal handler logic. Now promise cancellable will always have a default signal handler. The only way to prevent it is to supply your own custom handler, or within the executor, set the new PromiseCancellable((resolve, reject, signal) => {
signal.onabort = null;
}); Doing so will prevent the default signal handler. I thought I could remove the symbol species and rely on the |
Description
The
PromiseCancellable
requires testing in cancellation behaviour and its behaviour in DAGsIssues Fixed
Tasks
Final checklist