4 ms·
This doesn't even really appear to be a flaw in the Promise.race implementation [1], but rather a natural result of the fact that native promises don't have any
by throwitaway1123 2y ago
This doesn't even really appear to be a flaw in the Promise.race implementation [1], but rather a natural result of the fact that native promises don't have any notion of manual unsubscription. Every time you call the then method on a promise and pass in a callback, the JS engine appends the callback to the list of "reactions" [2]. This isn't too dissimilar to registering a ton of event listeners and never calling `removeEventListener`. Unfortunately, unlike events, promises don't have any manual unsubscription primitive (e.g. a hypothetical `removePromiseListener`), and instead rely on automatic unsubscription when the underlying promise resolves or rejects. You can of course polyfill this missing behavior if you're in the habit of consistently waiting on infinitely non-settling promises, but I would definitely like to see TC39 standardize this [3].
[1] https://issues.chromium.org/issues/42213031#comment5 https://issues.chromium.org/issues/42213031#comment5
[2] https://github.com/nodejs/node/issues/17469#issuecomment-349794909 https://github.com/nodejs/node/issues/17469#issuecomment-349...
[3] https://github.com/cefn/watchable/tree/main/packages/unpromise#under-the-hood-step-3---unsubscribe https://github.com/cefn/watchable/tree/main/packages/unpromi...
- kaoD 2y agoThis isn't actually about removing the promise (completion) listener, but the fact that promises are not cancelable in JS. Promises in JS always run to completion, whether there's a listener or not registered for it. The event loop will always make any existing promise progress as long as it can. Note that "existing" here does not mean it has a listener, nor even whether you're holding a reference to it. You can create a promise, store its reference somewhere (not await/then-ing it), and it will still progress on its own. You can await/then it later and you might get its result instantly if it had already progressed on its own to completion. Or even not await/then it at all -- it will still progress to completion. You can even not store it anywhere -- it will still run to completion! Note that this means that promises will be held until completion even if userspace code does not have any reference to it. The event loop is the actual owner of the promise -- it just hands a reference to its completion handle to userspace. User code never "owns" a promise. This is in contrast to e.g. Rust promises, which do not run to completion unless someone is actively polling them. In Rust if you `select!` on a bunch of promises (similar to JS's `Promise.race`) as soon as any of them completes the rest stop being polled, are dropped (similar to a destructor) and thus cancelled. JS can't do this because (1) promises are not poll based and (2) it has no destructors so there would be no way for you to specify how cancellation-on-drop happens. Note that this is a design choice. A tradeoff. Cancellation introduces a bunch of problems with promise cancellation safety even under a GC'd language (think e.g. race conditions and inconsistent internal state/IO). You can kinda sorta simulate cancellation in JS by manually introducing some `isCancelled` variable but you still cannot act on it except if you manually check its value between yield (i.e. await) points. But this is just fake cancellation -- you're still running the promise to completion (you're just manually completing early). It's also cumbersome because it forces you to check the cancellation flag between each and every yield point, and you cannot even cancel the inner promises (so the inner promises will still run to completion until it reaches your code) unless you somehow also ensure all inner promises are cancelable and create some infra to cancel them when your outer promise is cancelled (and ensure all inner promises do this recursively until then inner-est promise). There are also cancellation tokens for some promise-enabled APIs (e.g. `AbortController` in `fetch`'s `signal`) but even those are just a special case of the above -- their promise will just reject early with an `AbortError` but will still run to (rejected) completion. This has some huge implications. E.g. if you do this in JS... Promise.race([ deletePost(), timeout(3000), ]); ...`deletePost` can still (invisibly) succeed in 4000 msecs. You have to manually make sure to cancel `deletePost` if `timeout` completes first. This is somewhat easy to do if `deletePost` can be aborted (via e.g. `AbortController`) even if cumbersome... but more often than not you cannot really cancel inner promises unless they're explicitly abortable, so there's no way to do true userspace promise timeouts in JS. Wow, what a wall of text I just wrote. Hopefully this helps someone's mental model.
- rerdavies 2y agoBut if you really truly need cancel-able promises, it's just not that difficult to write one. This seems like A Good Thing, especially since there are several different interpretations of what "cancel-able" might mean (release the completion listeners into the gc, reject based on polling a cancellation token, or both). The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed.
- kaoD 2y agoWhy this isn't possible is implicitly (well, somewhat explicitly) addressed in my comment. const foo = async () => { ... // sync stuff A await someLibrary.expensiveComputation() ... // sync stuff B } No matter what you do it's impossible to cancel this promise unless `someLibary` exposes some way to cancel `expensiveComputation`, and you somehow expose a way to cancel it (and any other await points) and any other promises it uses internally also expose cancellation and they're all plumbed to have the cancellation propagated inward across all their await points. Unsubscribing to the completion listener is never enough. Implementing cancellation in your outer promise is never enough. > The javascript promise provides the minimum language implementation upon which more elaborate Promise implementations can be constructed. I'll reiterate: there is no way to write promise cancellation in JS userspace. It's just not possible (for all the reasons outlined in my long-ass comment above). No matter how elaborate your implementation is, you need collaboration from every single promise that might get called in the call stack. The proposed `unpromise` implementation would not help either. JS would need all promises to expose a sort of `AbortController` that is explicitly connected across all cancellable await points inwards which would introduce cancel-safety issues. So you'd need something like this to make promises actually cancelable: const cancelableFoo = async (signal) => { if (signal.aborted) { throw new AbortError() } ... // sync stuff A if (signal.aborted) { // possibly cleanup for sync stuff A throw new AbortError() } await someLibrary.expensiveComputation(signal) if (signal.aborted) { // possibly cleanup for sync stuff A throw new AbortError() } ... // sync stuff B if (signal.aborted) { // possibly cleanup for sync stuff A // possibly cleanup for sync stuff B throw new AbortError() } } const controller = new AbortController() const signal = abortController.signal Promise.cancelableRace( controller, // cancelableRace will call controller.abort() if any promise completes [ cancellableFoo(signal), deletePost(signal), timeout(3000, signal), ] ) And you need all promises to get their `signal` properly propagated (and properly handled) across the whole call stack.