-
Notifications
You must be signed in to change notification settings - Fork 12
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
Could this be constructed via a concept similar to zones instead? #24
Comments
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
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 // 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: 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) |
@Jamesernator (Apologies, long response.)
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 You can create natural analogues to 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
}
If you need direct subscription, use
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:
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 // 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, 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.
|
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
|
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. |
I've ran into a few sources of awkwardness in the past.
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.
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.
(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.
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 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 |
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 I suppose
I think node's Symbol |
@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
Possibly, but I haven't tried.
Yeah, but this was pre-
This is probably the biggest concern with this: absent anything like |
Edit:
s/token.try/token.run/g
, clarify that method's return semantics, remove language about an obvious optimizationEdit 2: Clarify what happens after you run a token, add
token.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.
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.The text was updated successfully, but these errors were encountered: