Open dead-claudia opened 5 years ago
Have you seen how F# does cancellation?
@benjamingr I had not yet, so the similarity was purely coincidental. It appears F# does a similar thing (the syntax here mimics F#'s Async.onCancel
), but it doesn't quite work the same way.
CancelToken
above is more like a CancellationTokenSource
, not a CancellationToken
.Also, it's much simpler and more direct for the user - you run a thunk with the cancel token, rather than getting the token from the source and calling a function with the token. It's also a bit more flexible and intuitive: this SO question regarding F# cancellation would be as simple as just invoking token.run(() => ...)
within the subroutine:
// runs until cancelled
async function subBlock() {
do cancel {
console.log("cancelled!")
}
while (true) {
console.log("doing it")
await sleep(100)
console.log("did it")
}
}
function main() {
let token = new CancelToken()
token.run(subBlock)
// loop to cancel CTS at each keypress
process.on("SIGINT", () => {
token.cancel()
console.log("restarting")
token = new CancelToken()
token.run(subBlock)
})
}
// Actually execute the script
process.on("unhandledRejection", e => { throw e })
main()
One of the problems with not having a reified token is that you couldn't do something like the old proposal's linkedTokens: new CancelTokenSource([parentToken, timeoutToken])
or something like CancelToken.any
/CancelToken.all
. Also sync observation is important for certain types of work which I can't see how to do with do cancel {
.
An alternative could be to automatically forward cancel tokens via a metaproperty when used as a call expression e.g.:
async function fetchWithTimeout(url, timeout) {
const timeoutSignalSource = new CancelSignalSource([function.cancelSignal])
setTimeout(() => timeoutSignalSource.cancel(), time)
// will abort if either the parent function cancels *or* if timeout occurs
return await fetch(url, { signal: timeoutSignalSource.signal });
}
async function doThing() {
// Because fetchWithTimeout( is a CallExpression the current function.cancelSignal
// will automatically be forwarded, so if doThing() is cancelled the cancellation propagates into
// fetchWithTimeout with no user input
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
// Will set function.cancelSignal within the body of doThing
doThing.fork(someCancelSignal)
@spion
@Jamesernator (Apologies, long response.)
One of the problems with not having a reified token is that you couldn't do something like the old proposal's linkedTokens:
new CancelTokenSource([parentToken, timeoutToken])
or something likeCancelToken.any
/CancelToken.all
.
It's a completely different model, and this doesn't really need the reified token to do everything. Those would make sense if you're being provided them, but this instead just provides subscriptions via a side channel. Could you come up with a concrete example of something where you can't do something equivalent (even if it doesn't share the same exact semantics)? Also, do you have a concrete example of when CancelToken.any
/CancelToken.all
would be necessary to do something?
You can create natural analogues to CancelToken.all
/CancelToken.any
, but these act more like if you defined those for CancelTokenSource
, not the token directly:
function any([...tokens]) {
const result = new CancelToken()
do cancel { result.cancel() }
function init() {
do cancel { result.cancel() }
}
for (const token of tokens) token.run(init)
return result
}
function all([...tokens]) {
const result = new CancelToken()
let remaining = tokens.length
do cancel { remaining = 0; result.cancel() }
function init() {
do cancel {
if (remaining !== 0) {
remaining--
if (remaining === 0) result.cancel()
}
}
}
for (const token of tokens) token.run(init)
return result
}
Also sync observation is important for certain types of work which I can't see how to do with
do cancel {
.
do cancel { ... }
blocks as I propose them here are synchonously called. So this isn't really an issue.
If you need direct subscription, use token.try(() => { do cancel { ... } })
and run your logic in the body of do cancel { ... }
.
An alternative could be to automatically forward cancel tokens via a metaproperty when used as a call expression e.g.:
async function fetchWithTimeout(url, timeout) { const timeoutSignalSource = new CancelSignalSource([function.cancelSignal]) setTimeout(() => timeoutSignalSource.cancel(), time) // will abort if either the parent function cancels *or* if timeout occurs return await fetch(url, { signal: timeoutSignalSource.signal }); } async function doThing() { // Because fetchWithTimeout( is a CallExpression the current function.cancelSignal // will automatically be forwarded, so if doThing() is cancelled the cancellation propagates into // fetchWithTimeout with no user input const result = await fetchWithTimeout(someUrl, 3000) doSomethingWithResult(result) } // Will set function.cancelSignal within the body of doThing doThing.fork(someCancelSignal)
In my proposal, you'd write that like this:
// This would ideally be included in the relevant specs, but they're
// here for show.
function fetch(url, opts) {
const controller = new AbortController()
do cancel { controller.abort() }
return window.fetch(url, {...opts, signal: controller.signal})
}
function setTimeout(...args) {
const timer = window.setTimeout(...args)
do cancel { clearTimeout(timer) }
return timer
}
async function fetchWithTimeout(url, timeout) {
const token = new CancelToken()
setTimeout(() => token.cancel(), timeout)
do cancel { token.cancel() }
// will abort if either the parent function cancels *or* if
// timeout occurs
return await token.run(async () => await fetch(url))
}
async function doThing() {
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
someCancelToken.run(doThing)
The big difference here is that you manually wire the new token's dependencies, but subscriptions and propagation are implicit. In the existing proposal, dependencies are mostly implicit, but subscription and propagation is explicit. Given you far more often want to subscribe rather than declare dependencies, I feel subscription would do better with as little boilerplate as possible.
(BTW, I also fixed a bug where you didn't clear the timeout on parent cancellation.)
You could could just add a hidden optional cancel token argument, accessible via metaproperty, and keep subscription explicit. This is roughly F#'s model, so there is precedent. This would alleviate the boilerplate in calling cancellable functions, but you'd have extra boilerplate in the callee side since you'd need to check if the user provided a signal to subscribe to if you don't keep a default no-op signal. (Most of the boilerplate with this would be alleviated by an optional chaining operator, though.)
I do see three axes on how you could track dependencies:
And here's how each point on those axes would work:
AbortController
+ AbortSignal
, and those also have a habit of getting boilerplatey.function.cancelSignal
idea. Solves the dependency boilerplate, but not really the subscription boilerplate.I left out the two with explicit propagation and implicit subscription. I'm pretty sure nobody uses that for any serious code, and it's unclear how you'd even make subscription implicit with signal propagation still being explicit.
Here's the fetchWithTimeout
example from my previous comment (with the fetch
wrapper implied), rewritten for each permutation:
// Explicit dependencies, explicit subscription, explicit propagation
async function fetchWithTimeout(url, timeout, {signal: parentSignal} = {}) {
const token = new CancelToken()
const timer = setTimeout(() => token.cancel(), timeout)
parentSignal.subscribe(() => { clearTimeout(timer); token.cancel() })
return await fetch(url, {signal: token.signal})
}
async function doThing({signal} = {}) {
const result = await fetchWithTimeout(someUrl, 3000, {signal})
doSomethingWithResult(result)
}
doThing({signal: someCancelToken.signal})
// Implicit dependencies, explicit subscription, explicit propagation
async function fetchWithTimeout(url, timeout, {signal: parentSignal} = {}) {
const token = new CancelToken([parentSignal])
const timer = setTimeout(() => token.cancel(), timeout)
parentSignal.subscribe(() => { clearTimeout(timer) })
return await fetch(url, {signal: token.signal})
}
async function doThing({signal} = {}) {
const result = await fetchWithTimeout(someUrl, 3000, {signal})
doSomethingWithResult(result)
}
doThing({signal: someCancelToken.signal})
// Implicit dependencies, explicit subscription, implicit propagation
async function fetchWithTimeout(url, timeout) {
const token = new CancelToken([function.cancelSignal])
const timer = setTimeout(() => token.cancel(), timeout)
function.cancelSignal.subscribe(() => { clearTimeout(timer) })
return await token.run(() => fetch(url))
}
async function doThing() {
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
someCancelToken.run(doThing)
// Explicit dependencies, explicit subscription, implicit propagation
async function fetchWithTimeout(url, timeout) {
const token = new CancelToken()
const timer = setTimeout(() => token.cancel(), timeout)
function.cancelSignal.subscribe(() => {
clearTimeout(timer)
token.cancel()
})
return await token.run(() => fetch(url))
}
async function doThing() {
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
someCancelToken.run(doThing)
// Explicit dependencies, implicit subscription, implicit propagation
async function fetchWithTimeout(url, timeout) {
const token = new CancelToken()
const timer = setTimeout(() => token.cancel(), timeout)
do cancel { clearTimeout(timer); token.cancel() }
return await token.run(() => fetch(url))
}
async function doThing() {
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
someCancelToken.run(doThing)
// Implicit dependencies, implicit subscription, implicit propagation
async function fetchWithTimeout(url, timeout) {
const token = new CancelToken()
const timer = setTimeout(() => token.cancel(), timeout)
do cancel { clearTimeout(timer) }
return await token.run(() => fetch(url))
}
async function doThing() {
const result = await fetchWithTimeout(someUrl, 3000)
doSomethingWithResult(result)
}
someCancelToken.run(doThing)
(In the last one, CancelToken
registers a do cancel { this.cancel() }
in its constructor.)
Of course, anything with explicit propagation (passing via arguments) is a bit too boilerplatey, so the first two are out the window. Explicit subscription has pitfalls I detailed above, so I'm moderately against it. So the last question is whether it's better to go with explicit or implicit linkage.
Now that I'm taking a second look at this suggestion, I'm starting to feel the version where subscription, propagation, and dependencies are all implicit is probably the ideal combo.
nullptr
s for the "block" and the token itself for the "context".do cancel
blocks it knows can't be called.Just wanted to chime in with another case where this could be helpful: subscription management. This model would simplify observables greatly while giving them an easier ability to monitor cancellation while initializing, and it'd let you remove promise .then
callbacks without necessarily cancelling the promise. (You can only cancel what you start.)
do cancel { ... }
inside the callback itself.subscribe
method, an internal do cancel
would just set the observer to no longer emit, a very cheap operation.then
method, an internal do cancel
would remove the subscription without cancelling the promise. A conceptual observable Subject
would do similar.Seems interesting. I never had that much of an issue with threading tokens though my APIs in C#, but that probably said more about me than anything else...
What do multiple cancellation tokens look like, e.g. http response header timeout (incl. redirects) vs body/total cancellation, which could be from different sources? I assume the explicit propagation / subscription would still be available as a fallback.
Could you "escape" CancelToken.run, if you were doing something weird (eg pushing work onto a queue or using web workers or something) which wouldn't fit into the standard async / await pattern? It looks like you might be able to stash the context signal and return a fresh Promise you'll resolve later to CancelToken.run, but it seems to be missing a way to clear the current token and restore it into a current context later: maybe CancelToken.none.run(fn), or a mutable function.cancelSignal?
Can transpilers easily simulate this? In particular, carrying the implicit signal across an await.
@simonbuchan
Seems interesting. I never had that much of an issue with threading tokens though my APIs in C#, but that probably said more about me than anything else...
I've ran into a few sources of awkwardness in the past.
What do multiple cancellation tokens look like, e.g. http response header timeout (incl. redirects) vs body/total cancellation, which could be from different sources? I assume the explicit propagation / subscription would still be available as a fallback.
You compose through nesting, and when a parent cancels, all its children also cancel. Here's something a little more real-world where this would be necessary.
async function getUser(id) {
const headerTimeout = new CancelToken()
const response = await headerTimeout.run(async () => {
const headerTimer = setTimeout(() => timer.cancel(), 5000)
const ctrl = new AbortController()
do cancel { ctrl.abort() }
try {
return await timeoutToken.run(() =>
fetch(`https://example.com/api/user/${id}`, {signal: ctrl.signal})
)
} finally {
clearTimeout(headerTimer)
}
})
if (!headerTimeout.active) throw new Error("timed out")
return response.json()
}
function User({id}) {
const [user, setUser] = useState()
useEffect(() => {
const token = new CancelToken()
token.run(() => getUser(id)).then(setUser)
return () => token.cancel()
}, id)
// return view
}
Explicit propagation and subscription aren't precisely available - by design I'd rather not allow this to be fully detached from scope to prevent memory leaks.
Could you "escape" CancelToken.run, if you were doing something weird (eg pushing work onto a queue or using web workers or something) which wouldn't fit into the standard async / await pattern? It looks like you might be able to stash the context signal and return a fresh Promise you'll resolve later to CancelToken.run, but it seems to be missing a way to clear the current token and restore it into a current context later: maybe CancelToken.none.run(fn), or a mutable function.cancelSignal?
In queues, you'd use a separate cancel token created immediately, then used when invoking the task:
function enqueue(task) {
const child = new CancelToken()
queue.push({task, token: child})
}
function drain() {
const oldQueue = queue
queue = []
for (const {task, token} of oldQueue) token.run(task)
}
In web workers, you'd use allocated request IDs in the parent thread to keep request IDs straight, deallocating them when you receive their responses, and on the child thread, you'd use cancel tokens (if desired) to handle task cancellation and abstract the mess. It's non-trival, but it's non-trivial no matter what model you use.
// In parent thread
const completions = new Map()
worker.onmessage = ({data}) => {
const completion = completions.get(data.reqid)
if (completion == null) return
completions.delete(data.reqid)
if (data.type === "success") {
completion.resolve(data.result)
} else {
completion.reject(data.error)
}
}
function run(task) {
return new Promise((resolve, reject) => {
const reqid = getUniqueID()
completions.set(reqid, {resolve, reject})
do cancel {
completions.delete(reqid)
worker.postMessage({type: "cancel", reqid})
}
worker.postMessage({type: "run", reqid, task})
})
}
// In worker thread
const tokens = new Map()
self.onmessage = async ({data}) => {
if (data.type === "cancel") {
// cancel request
const token = tokens.get(data.reqid)
tokens.delete(data.reqid)
if (token != null) token.cancel()
} else {
const token = new CancelToken()
tokens.set(data.reqid, token)
token.run(() => doTask(data.task))
.finally(() => tokens.delete(data.reqid))
.then(
result => self.postMessage({type: "success", reqid, result}),
// Technically wrong - you have to serialize errors specially
error => self.postMessage({type: "error", reqid, error})
)
}
}
function doTask(task) {
// do stuff
}
I've considered a few ways to save the current token, such as these, but I found the above pattern to be sufficient for most use cases.
CancelToken.current
function.cancelToken
CancelToken.getCurrent()
(I've got real-world scenarios where I needed this, so I didn't miss it.)
There are issues I haven't sorted out yet, like how to handle cross-realm cancellation. I just wanted to get the general shape of this out there first.
Can transpilers easily simulate this? In particular, carrying the implicit signal across an await.
Yes, provided the polyfill offers a hook for transpilers to add cancellation callbacks and to save/restore cancellation contexts. There will be the limitation that APIs using async
/await
or generators internally won't be able to have it correctly polyfilled, much like how you couldn't transparently polyfill typeof
for symbols. Something like this would work - it's a rough polyfill of this mod the promise parts:
const queueStack = []
// For transpilers:
// `do cancel { ... }` -> `doCancel(q, () => { ... })`
// `try { A } finally { B() }` -> prefix with `doCancel(q, () => { /* finally */ })`
export function doCancel(queue, callback) {
if (queue != null) queue.push(callback)
}
// Used at the top of every generator, `async` function, and function with
// `do cancel` blocks.
export function getQueue() {
return queueStack.length
? queueStack[queueStack.length - 1]
: undefined
}
// `yield x` -> `setQueue(q, yield popQueue(x))`
// `await x` -> `setQueue(q, await popQueue(x))`
export function popQueue(v) { queueStack.pop(); return v }
export function setQueue(q, v) { queueStack.push(p); return v }
// And the global polyfill itself:
export class CancelToken {
constructor() {
this._callbacks = []
// Equivalent to `do cancel { ... }`
doCancel(getQueue(), () => this.cancel())
}
get active() {
return this._callbacks != null
}
cancel() {
const queue = this._callbacks
this._callbacks = undefined
if (queue != null) {
for (const f of queue) f()
}
}
run(func) {
if (this._callbacks == null) {
throw new ReferenceError("Token has already been cancelled")
}
queueStack.push(this._callbacks)
try {
return func()
} finally {
queueStack.pop()
}
}
}
I did consider a pure userland API for subscription using something along the lines of CancelToken.onCancel
, but I was concerned about optimizability of it, specifically when it's not called with any cancel token. Syntax makes the overhead virtually zero-cost and lets engines elide the closure allocation a little more easily at the bytecode level. (Most potentially cancellable things aren't done in hot paths, so this could make a noticeable difference.)
Wow, thanks for the pretty complete response! A lot of good detail and thought there.
I've been running through different situations, and I think they should all be fine, with some work maybe, but it is still somewhat confusing. I am a bit concerned about avoiding an extra parameter (admittedly, a lot of one extra parameter, though with options bags maybe not that bad?) by adding an new kind of scope to think about to the language. Let alone my non-language geek co-workers, I already have enough trouble keeping the async and sync stack trace straight!
After I spent a little time looking at the Zones proposal, this one made a lot more sense, but I really did need the reification to really understand what was happening, e.g. that something like this happens:
const oldAddListener = EventTarget.prototype.addListener;
EventTarget.prototype.addListener = function (type, listener) {
listener = Zone.current.wrap(listener)
oldAddListener.call(this, type, listener)
};
It seems Zones are DoA given node rejecting the error-handling version due to smelling like domains, and the non-error-handling version being basically node's async_hooks
. If that's for sure, would you be opposed to inheriting, roughly, some version of the Zone
API into this? E.g. listener = CancelToken.current.wrap(listener)
? It's a larger surface area, but TC39 seemed pretty happy with Zones excluding the error handling behavior, it might not be a problem. It makes it easier to describe the behavior, and simpler to integrate into existing libraries, and to wrap existing ones.
I suppose fn = CancelToken.current.wrap(fn)
is equivalent to const t = new CancelToken(); fn = () => t.run(fn); }
, though it's a lot less obvious....
There will be the limitation that APIs using
async
/await
or generators internally won't be able to have it correctly polyfilled,
I think node's async_hooks
should work? Don't think there's a browser equivalent though, apparently Angular's zone implementation monkey-patched all the APIs (woof).
Symbol typeof
wasn't too blocking a deal, Symbols were completely usable without that with a tiny bit of care, but not being able to polyfill / transpile context of async functions seems like a showstopper to me. That said, I think internal async / await usage of cancellation-unaware APIs should be fine, since the aware code will re-establish it before any more on cancel
blocks?
@simonbuchan I considered using zones more directly and using a similar concept, but the extra explicit plumbing boilerplate you'd end up having to do 100% of the time in practice is what drove me away from the zones API in its current form. (The design for zones could be adapted to use something like what I use here, though, and it could be a stepping stone for effects.) I also had perf concerns about always-allocated callbacks - an implementation for this only needs to allocate the closure when cancellation could actually occur.
The do cancel
specifically is something I'm not beholden to, though. I do want to reiterate that.
I think node's
async_hooks
should work?
Possibly, but I haven't tried.
Don't think there's a browser equivalent though, apparently Angular's zone implementation monkey-patched all the APIs (woof).
Yeah, but this was pre-async
/await
and they didn't have to work around generators because it was all based in global state and not lexical state (as this is). So it was technically possible for them to monkey-patch everything they needed to.
but not being able to polyfill / transpile context of async functions seems like a showstopper to me
This is probably the biggest concern with this: absent anything like async_hooks
, you can't have it just through transpiling your own code and introducing global polyfills. You have to transpile even your dependencies to work with this. (At least it's almost a simple regexp replacement and it only marginally requires static analysis.)
Edit:
s/token.try/token.run/g
, clarify that method's return semantics, remove language about an obvious optimization Edit 2: Clarify what happens after you run a token, addtoken.active
The zones proposal suggested using lexical scope and hidden global variable tracking to track cancellation. And I was thinking, couldn't a similar basic concept, a hidden lexical variable, be used to track cancellation? I decided to simplify it, streamline it, and make it purely syntactic, and here's my thought:
do cancel { ... }
do\ncancel\n{
-do cancel\n
can currently only legally be followed by awhile
.return
, but you canthrow
. If any block throws, the remaining blocks are still always executed, and the last error thrown from the loop is rethrown with the rest swallowed.Promise
constructor, you can manually cancel via invoking a thirdcancel()
callback.then
methods, includingPromise.prototype.then
would receive a thirdonCancel
callback in addition to their existingonResolve
/onReject
callbacks.Promise.cancel()
existing to reify this state for easy use.do cancel
blocks in order of appearance. If cancellation was sync, it just returns undefined. Otherwise, it resumes with a "cancel" completion that causesfinally
blocks andPromise.prototype.finally
callbacks to be invoked, butcatch
blocks to be ignored.do cancel
blocks are retained as long as the token they're registered to is. If it was called without a token, the block is just ignored.token = new CancelToken()
instance with two methods: atoken.cancel()
method to cancel and atoken.run(func)
to invokefunc
with the token itself registered as the current cancel token and return the returned value.token.run
doesn't actually inspect the return value offunc
. It doesn't tail-call since it needs to restore previous state, but the return value is proxied through untouched.token.run
doesn't invoke the callback if the token has already been canceled.token.run
doesn't clear callbacks after it runs. It's up to the user to clean everything up. (This is much like how it is today.)token.active
istrue
initially,false
oncetoken.cancel()
is called.Here's how it might look in practice.
```js // Snippet based on this MDN example: // https://github.com/mdn/dom-examples/blob/master/abort-api/index.html const url = 'sintel.mp4' let token const videoWrapper = document.querySelector('.videoWrapper') const downloadBtn = document.querySelector('.download') const abortBtn = document.querySelector('.abort') const reports = document.querySelector('.reports') downloadBtn.addEventListener('click', fetchVideo) abortBtn.addEventListener('click', () => { if (token != null) { token.cancel() token = undefined } console.log('Download aborted') downloadBtn.style.display = 'inline' }) function fetchVideo() { token = new CancelToken() token.run(async () => { downloadBtn.style.display = 'none' abortBtn.style.display = 'inline' reports.textContent = 'Video awaiting download...' try { const response = await fetch(url) runAnimation() setTimeout(() => console.log('Body used: ', response.bodyUsed), 0) const myBlob = await response.blob() const video = document.createElement('video') video.setAttribute('controls', '') video.src = URL.createObjectURL(myBlob) videoWrapper.appendChild(video) videoWrapper.style.display = 'block' abortBtn.style.display = 'none' downloadBtn.style.display = 'none' reports.textContent = 'Video ready to play' } catch (e) { reports.textContent = 'Download error: ' + e.message } }) } function runAnimation() { let animCount = 0 const progressAnim = setInterval(() => { reports.textContent = 'Download occuring; waiting for video player to be constructed' + '.'.repeat(animCount) animCount = (animCount + 1) % 4 }, 300) do cancel { clearInterval(progressAnim) } } // Snippet based on the pen from this blog post: // https://medium.com/@bramus/cancel-a-javascript-promise-with-abortcontroller-3540cbbda0a9 // Example Promise, which takes cancellation into account function doSomethingAsync() { return new Promise((resolve, reject, cancel) => { document.getElementById('log').textContent = 'Promise Started' // Something fake async const timeout = window.setTimeout(resolve, 2500, 'Promise Resolved') // Listen for cancel do cancel { window.clearTimeout(timeout) cancel() } }) } // Creation of a cancel token const token = new CancelToken() // Start our promise, catching errors (including cancel) const start = e => { e.preventDefault() token.run(async () => { do cancel { document.getElementById('log').textContent = 'Promise Aborted' } try { const result = await doSomethingAsync() document.getElementById('log').textContent = result } catch (e) { document.getElementById('log').textContent = 'Promise Rejected' } }) } // Stop the promise (by calling cancel) const stop = e => { e.preventDefault() token.cancel() } // Hook events to buttons document.getElementById('start').addEventListener('click', start) document.getElementById('stop').addEventListener('click', stop) ```The reason I went with a mix of syntax with execution context internal slots and built-in objects has a few reasons:
If you squint hard enough, you might notice some visual similarities to the syntax and semantics of the recent React-style hooks. That is purely incidental and I have zero plans to propose such a thing. Its relation to that is like how the type
T
relates to() => T
(orvoid (*)(T)
if you're more familiar with C/C++) - they might look similar, but it's pretty obvious they're fundamentally a bit different.