This was originally posted on my Medium account; I've reposted it here for posterity.
Adding Promise Support to Node.js Core
Two Sundays ago, I opened a pull request to the nodejs/node
repository introducing support for Promises. There's been a lot of
discussion since that point, with over 400 comments in the main thread alone.
Several discussions have split into other issues. This post will summarize the
state of the discussion as it stands.
Why add Promises?
Given that many discussions around promises turn adversarial, and that solutions currently exist in the ecosystem, why include them in core?
While it's true that the topic can be contentious, I believe that this is largely due to the framing of the conversation. It is often set up as a zero-sum game: for promise users to make gains, callback users have to lose ground, or vice versa. However, I believe that we can frame this in a more productive fashion. If we accept that both callbacks and promises are both valid ways to build asynchronous programs, the discussion becomes about better supporting a set of users, instead of about invalidating current practices. Callbacks are, and will continue to be, a sturdy, reliable pattern on top of which to build your application — as will promises. This discussion is about making life easier for a set of our users without incurring a cost for another set of users. It is not about which pattern is best.
It's natural to ask, "Why put them in Core, if they're already useful as-is?"
The current situation front-loads a lot of decisions onto authors of promise-based packages. That is, if I and another package author wish to interoperate using promises, that agreement alone is insufficient for us to proceed.
In order to interoperate, we have to agree on the shim, implementation, and specific version of promises in order to allow promises to safely be used between packages. Otherwise, a tricky (and manual) process of wrapping and unwrapping promises at the edges of the package APIs is necessary. For most users, both of these approaches are not ideal — they just want to use native promises, not specify all of the details. Users that wish to use different implementations still can, but the requirement of marshalling and unmarshalling at package boundaries become "opt-in" for the majority of users.
Compounding this situation, async/await
syntax uses promises as the
underlying mechanism for representing asynchrony. Users that wish to use this
feature, at present, will have to pick a shim, implementation, and version in
order to use this syntax with Node APIs. This introduces friction for newcomers
to Node, especially those coming from the frontend where their preferred
patterns "just work".
Exposing a Promise API aligns our interests with the interests of our
underlying VM. Promises are already part of the Node platform, by way of V8 and
the JavaScript specification. The language has woven them into multiple
syntaxes now — from the module specification to async/await
. Some number of
our users will expect Node to provide a JavaScript runtime comparable to
browsers environments. Node has a history of trying to align these platforms —
from small things, like making the timer API look-and-feel like the
setTimeout
offered by the browser, to bigger things, like providing users our
own TypedArrays in-tree before our vendored V8 offered them.
Finally, this helps heal a rift in the community. By including a core promise API alongside the callback API, at a platform level we reinforce the notion that both patterns are valid. It does not matter to Node which pattern suits you best. This makes participating in the Node ecosystem and in Node core more welcoming for promise users.
The Approach
The callback API will not change to suit the promise API. It will remain the
same. The current approach installs a promisified version of each method
alongside the original method. We have the option of exposing core's
promisification method via util
, but currently it is internal to core.
In presenting the promise API as a regular transformation on top of the callback layer, we ensure that the maintanence cost of the promise layer is a small constant added to the maintanence cost of the callback layer. We also ensure that callback users are not paying a performance tax to support promise users.
This approach doesn't preclude the later inclusion of a submodule or ES2015 module-based approach to surfacing a promise API, however, that is out of scope for this PR. In that vein, returning a promise from callback APIs when no callback is passed is not workable because some callback APIs already have significant return values separate from their callback value. Additionally, a few crypto APIs are synchronous when called without a callback.
The API is experimental and if approved will land behind a flag,
--enable-promises
, which will be set to false
by default. Work may continue
on the API once it has landed. It would be at least one major version until
the flag is lifted, and if the API proves untenable to support it may be
removed before that point. At least two major versions later, it would become
officially supported, meaning that it will not enter LTS support until another
year after that, as I understand it. The timeline on this feature is long and
there are go/no-go checks before it becomes supported.
The earliest a flagged version could land is the week of the 22nd, February 2016. If landed, the first go/no-go check for unflagging would happen in October 2016.
const fs = require('fs')
fs.readFilePromise('some/file').then(data => {
console.log(data)
})
// shortcut for top-level promise API
const {readFile} = require('fs').promised
readFile('some/file').then(data => {
console.log(data)
})
Only "single occurrence" async APIs provide promise variants at the moment. In practice, this means that the following core modules will include promise variants:
cluster
child_process
crypto
dgram
dns
fs
zlib
Some methods of net.Socket
, readline
, and repl
also provide promise
variants.
The Node Core Technical Committee is planning on meeting to determine how to move forward on this PR sometime during the week of the 22nd.
Technical Issues & Proposed Solutions
AsyncWrap, Domains and Microtask Queue
Native promises currently interact incorrectly with Domains and AsyncWrap. This is largely due to a lack of visibility into, and control over, V8's "microtask queue." The microtask queue is a language-mandated queue of functions to be run after the exhaustion of a JavaScript stack. In the case of promises, settling a promise with pending handlers OR the addition of handlers to a settled promise is mediated through the microtask queue. AsyncWrap currently relies on Node's ability to exert full control over the top-level entry points from event loop to JavaScript in order to provide hooks for users to track new sources of asynchrony. Domains also lean on this ability. The microtask queue is opaque to Node — Node cannot see when functions are added to the queue, or specifically when those functions are executed inside the queue. Given this, it's impossible for Node to fire the appropriate AsyncWrap hooks, or to associate the appropriate domain with a promise handler.
I am exploring making V8's Microtask Queue pluggable, in the style of the
ArrayBuffer::Allocator
API. This should allow us to add the necessary hooks
for AsyncWrap
and domains, while affording us the opportunity to consolidate
the nextTick
and microtask queues into a single queue.
Promises Unsuitable for Post Mortem Debugging Tools
The @nodejs/post-mortem working group has expressed concerns about Promise
use interfering with post-mortem debugging tooling. Specifically, the reliance
on using throw
as a mechanism for error propagation means that in many cases,
processes that crash on unhandled rejection will have already unwound the
stack. Unwinding the stack is highly undesirable for post-mortem purposes, both
because the stack contains stack frame parameter information (which Error
objects lack), as well as because distancing the exceptional state from the
serialization of program state means that valuable heap information may have
changed before the program was serialized.
Two mitigations are currently being investigated:
- Skip intermediate non-user-installed handlers on promise rejection. This
allows the rejection to propagated to the
unhandledRejection
handler without unwinding the stack. Change the defaultunhandledRejection
behavior so that in the absence of a user-installedprocess.on('unhandledRejection')
listener the process crashes, and investigate making synchronous rejections signalunhandledRejection
immediately, versus the current "on next tick" behavior. These steps would combine to make unhandled rejections work "as expected" with current post-mortem tooling, and align unhandled rejection behavior with uncaught exception behavior. This also makes explicit an unwritten rule already in effect for package authors, which is "avoid leaning on unhandled rejection behavior," since authors cannot currently rely on consumers not to crash the program when unhandled rejections emerge. See this comment for more. - Explore adding a V8
Context
-associated hook for VM-originated programmer errors, likeReferenceError
,TypeError
, etc. This would allow post-mortem users to short-circuit promise (& try/catch) machinery and abort on programmer error.
I believe that we should have a clear answer for the Post Mortem WG's concerns before unflagging the feature, but not necessarily before landing the PR.
Rejecting Operational Errors Hides Programmer Errors
Some users wish to limit the use of try/catch
to truly exceptional,
unexpected situations. Node currently caters to this need by only throwing
exceptions from asynchronous methods if the provided arguments are invalid for
the API in question. These users would prefer to continue to reserve exceptions
for unexpected behavior as they start using async/await
. Most existing shims,
and indeed the original PR, treat operational errors — EEXIST
, ENOENT
,
EMFILE
— as rejections, which map to thrown exceptions in async/await
programs. As an example:
async function reader (filename) {
try {
const data = await fs.readFilePromise(filename)
} catch (err) {
// could be a programmer error ("filename was undefined") or
// it could be an operational error ("filename does not exist").
}
}
For many users the above example represents acceptable use and is analogous to
the fs.readFileSync
API. Others wish to keep programmer errors separated from
operation errors.
There are three potential mitigations:
- Via @littledan: When implementing the Promise API, use destructuring to
return
[err, value]
. - Via @zkat: Allow a
recovery
object to be passed to Promise-wrapped functions in order to specify behavior in case of operational errors. - If implemented, the first mitigation approach from the post-mortem section
would separate programmer errors from operationals error by crashing the
programs lacking a
process.on('unhandledRejection')
handler on synchronous rejection.
Examples follow:
// Mitigation 1 with async/await
// - err may be a programmer error _or_ a operational error
const [err, value] = await fs.readFilePromise(path)
// Mitigation 1 with raw promises
fs.readFilePromise(path).then(([err, value]) => {
})
// Mitigation 2 with async/await
// - throws exception on bad "path" param
const value = await fs.readFilePromise(path, {
ENOENT () {
return null
}
})
// Mitigation 2 with raw promises
fs.readFilePromise(path, {
ENOENT () {
return null
}
}).then(value => {
})
// Mitigation 3
// - throws operational errors (ENOENT)
// - when passed a bad path, crashes the program (regardless
// of try/catch)
const data = await fs.readFilePromise(path)
Where to Participate
The main PR is the best place to participate. I've added a FAQ at the top with links to specific responses to make navigating the thread a bit easier. If you don't have the time to read the entire thread, please continue share your concerns and I will attempt to address and direct them as they come in. If you're nervous about wading into the thread, please feel free to contact me directly at christopher.s.dickinson at gmail dot com, or via Twitter @isntitvacant.
Thanks for your time!