Background and assumptions:
Consider that a promise is "complete" when its promisified (or wrapped) function exits or returns (line 10 below). Consider a promise as "settled" if it is resolved (or fulfilled, line 4 below), or rejected (line 6 below) by the time it completes.
Everyone is familiar with these terms, and all is good so far. However there is yet another class of outcomes that is not well documented or widely discussed - unsettled promises. Consider a promise as "unsettled" if it completes, but has neither been resolved nor rejected (reaches line 8 below). A completed promise may be fulfilled or rejected later (sometime after completion) through asynchronous means, but if it was not fulfilled/rejected by the time it completed, it is nonetheless considered "unsettled".
1 prepareNextSearch(): Promise<PrepStatus> {
2 return new Promise((resolve, reject) => {
3 if (this.canForp()) {
4 resolve('I can forp'); <====== Resolved
5 } else if (this.canRarp()) {
6 reject('I cannot forp but I can rarp'); <====== Rejected
7 } else {
8 console.log('I cannot promise anything'); <====== Unsettled
9 }
10 }); <====== Completed
11 }
So consider the following outcomes for a completed promise (not to be confused with possible states of a promise):
- Resolved (via line 4)
- Rejected (via line 6)
- Unsettled (via line 8)
The problem:
Currently, an unsettled promise will silently ignore any code in any subsequent chained promise attachments (then/catch/finally), or analogously, code that comes after an await in an async function and all outer async functions in a call-stack. In other words, unsettled promises will suddenly and silently break from the logical thread of execution with no way to catch them.
Example of problematic behavior of an unsettled promise (from prepareNextSearch) using the Promise pattern (same results with the async/await pattern):
1
2 youllBeSorry() {
3 const nextSearch = this.prepareNextSearch()
4 .then(promised => {
5 console.log(promised); // <=========== NOT REACHED!
6 }).catch(error => {
7 this.prepAltSearch(); // <=========== NOT REACHED!
8 }).finally(() =>
9 console.log( // <=========== NOT REACHED!
10 'Whew! The search preparation completed one way or another.'
11 );
12 });
13
14 this.doOtherStuffWhileSearching();
15 }
If run with no capability for the promise of this.prepareNextSearch()
to "forp" or "rarp" (for the sake of argument), lines 5, 7 and 9 will not execute - indeed, even finally
will be silently skipped over. The string "Whew! The search preparation completed one way or another." will never be logged, and the program will happily carry on at line 14 as if nothing was ever promised, and no prep will be done.
This behavior is not well-documented, and for the vast majority of cases, unexpected and undesired. Many would consider it counterintuitive to the semantics of finally blocks (at the least), which ultimately never runs. Further, there is no prescribed way to catch and handle these exceptional cases.
"A pending promise can either be fulfilled with a value or rejected with a reason (error)."
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
Proposal 1:
The first proposal is for Promise
to throw an "UnsettledPromiseError" (or similar) when an unsettled promise occurs, providing a way to catch and handle these exceptional cases.
Proposal 2:
An alternate proposal is to provide built-in semantics to catch and handle unsettled promises as first-class citizens with an "unsettled" handler within Promise.then() and optionally a corresponding "unsettled" block.
Examples:
class ExploreTheUnknown {
/**
* Prepare a search with algo1 (falling back to algo2), execute the search
* and then clean up.
*/
searchExoplanets(): Promise<Exoplanet[]> {
return this.prepareAlgo1Search()
.catch(error => {
console.warn(`Search prep algo 1 failed, fallback to algo 2: ${error}`);
return this.prepareAlgo2Search();
})
.then(() => this.findExoplanets())
.finally(() => {
console.log('The search has ended.');
this.freeResources();
});
}
freeResources(): void {
this.deleteWorkingDir();
}
prepareAlgo1Search(): Promise<void> {
return new Promise((resolve, reject) => ...);
}
prepareAlgo2Search(): Promise<void> {
return new Promise((resolve, reject) => ...);
}
findExoplanets(): Promise<Exoplanet[]> {
return new Promise((resolve, reject) => resolve(['LHS 3844b']));
}
If prepareAlgo1Search()
fails for any reason, we want to run the less optimal but still acceptable prepareAlgo2Search()
method. In all cases, we need to perform some cleanup after our search or we'll have a major resource leak on our hands. With current behavior, our search for the unknown might never be conducted depending on the outcome of prepareAlgo1Search()
, and resource leaks could very well happen eventually.
With proposal 1, the code would run as expected with no changes and would conduct our search (unless both algos fail) and always clean up afterwards, regardless (under normal operating conditions). We could demonstrate proposal 1 further and report on any underlying issues like so:
searchExoplanetsWithUnsettledException(): Promise<Exoplanet[]> {
return this.prepareAlgo1Search()
.catch(error => {
if (error instanceof UnsettledPromiseError) {
console.warn('Search prep algo 1 is unsettled (possible bug in algo 1), fallback to algo 2.');
} else {
console.warn(`Search prep algo 1 failed, fallback to algo 2: ${error}`);
}
return this.prepareAlgo2Search();
})
.then(() => this.findExoplanets())
.finally(() => {
console.log('The search has ended.');
this.freeResources();
});
}
Proposal 2 might look something like:
searchExoplanetsWithUnsettledHandler(): Promise<Exoplanet[]> {
return this.prepareAlgo1Search()
.then(
// Resolved handler.
() => undefined,
// Error handler.
(error: any) => {
console.warn(`Search prep algo 1 failed, fallback to algo 2: ${error}`);
return this.prepareAlgo2Search().unsettled(() => {
throw new Error('Search prep algo 2 is unsettled (possible bug in algo 2), giving up on search prep.');
});
},
// Unsettled handler.
() => {
console.warn('Search prep algo 1 is unsettled (possible bug in algo 1), fallback to algo 2.');
return this.prepareAlgo2Search().unsettled(() => {
throw new Error('Search prep algo 2 is unsettled (possible bug in algo 2), giving up on search prep.');
});
}
)
.then(() => this.findExoplanets())
.finally(() => {
console.log('The search has ended.');
this.freeResources();
});
}
Proposal 1 seems cleaner with more of what one might expect from try/catch/finally semantics. Proposal 2 is more complex, a bit more redundant, but perhaps more flexible and backwards-compatible with the current behavior (albeit behavior that is unexpected and undesired in most cases).
The promise (no pun intended) of curtailing programming errors with Promises is mostly fulfilled, but still needs to address this important class of outcomes.
"Promises solve a fundamental flaw with the callback pyramid of doom, by catching all errors, even thrown exceptions and programming errors."
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises