Skip to content
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

Open
dead-claudia opened this issue Apr 5, 2019 · 11 comments
Open

Comments

@dead-claudia
Copy link

dead-claudia commented Apr 5, 2019

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, 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:

  • You can set a manual cancellation callback by using the special form do cancel { ... }
    • This schedules the block with the current lexical context to be run when the token is cancelled.
    • This is not grammatically ambiguous, even if you type it as do\ncancel\n{ - do cancel\n can currently only legally be followed by a while.
    • For obvious reasons, you can't return, but you can throw. 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.
  • In the Promise constructor, you can manually cancel via invoking a third cancel() callback.
    • Likewise, then methods, including Promise.prototype.then would receive a third onCancel callback in addition to their existing onResolve/onReject callbacks.
  • A new "canceled" promise state would exist for async promise cancellation, with Promise.cancel() existing to reify this state for easy use.
  • When you cancel, it executes all previously scheduled do cancel blocks in order of appearance. If cancellation was sync, it just returns undefined. Otherwise, it resumes with a "cancel" completion that causes finally blocks and Promise.prototype.finally callbacks to be invoked, but catch 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.
  • I don't propose any significant composition operator, since it's all lexical. Beyond that, the only meaningful composition is nesting and defining another block.
  • To actually control and trigger cancellation, you use a token = new CancelToken() instance with two methods: a token.cancel() method to cancel and a token.run(func) to invoke func with the token itself registered as the current cancel token and return the returned value.
    • token.run doesn't actually inspect the return value of func. 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 is true initially, false once token.cancel() is called.
Here's how it might look in practice.
// 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:

  1. It avoids most of the plumbing boilerplate. You don't need to manually subscribe 99% of the time, and things just work.
  2. It keeps it easy to procedurally cancel an action, while it keeps it easy to declaratively schedule work to run on cancel.
  3. It encourages people to black-box cancellation through the implicit behavior, which itself leads to more decoupled and composable cancellation semantics.

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 (or void (*)(T) if you're more familiar with C/C++) - they might look similar, but it's pretty obvious they're fundamentally a bit different.

@benjamingr
Copy link

Have you seen how F# does cancellation?

@dead-claudia
Copy link
Author

dead-claudia commented Apr 7, 2019

@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.

  • Like it, this fully integrates with scoping and works in part via a global field.
  • Like it, this cancels via an exception-like termination.
  • Unlike it, the termination is not a standard exception - there is no way to prevent user-initiated cancellation. (This is a pretty big invariant that IMHO C#/F# should have had.)
  • Unlike it, this does not give easy access to the current global cancel token by default.
  • Unlike it, this has no dedicated "cancel token". My proposed 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()

@Jamesernator
Copy link

Jamesernator commented May 10, 2019

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)

@benjamingr
Copy link

@spion

@dead-claudia
Copy link
Author

@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 like CancelToken.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.)

@dead-claudia
Copy link
Author

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:

  • Dependencies: explicit dependencies mean you have to explicitly cancel parent tokens, implicit means you can just link them and they're connected to implicitly, or with implicit propagation, they're also linked for you.
  • Subscription: explicit subscription means you have to explicitly reference a signal to subscribe to it, implicit means you subscribe to a signal indirectly via syntax or a DSL of some form.
  • Propagation: explicit propagation means you have to explicitly pass a signal (or reference to it) via argument to functions, implicit means it's normally passed for you and you only need to explicitly change it.

And here's how each point on those axes would work:

  • Explicit dependencies, explicit subscription, explicit propagation: This is what C# does, and it's boilerplatey as heck.
  • Implicit dependencies, explicit subscription, explicit propagation: This is the current proposal as well as AbortController + AbortSignal, and those also have a habit of getting boilerplatey.
  • Explicit dependencies, explicit subscription, implicit propagation: This is roughly F#'s model. The cancel signal is implicitly propagated, but you have to explicitly read it to subscribe to it, and you have to manually wire dependencies in a similar fashion to C#.
  • Implicit dependencies, explicit subscription, implicit propagation: Your function.cancelSignal idea. Solves the dependency boilerplate, but not really the subscription boilerplate.
  • Explicit dependencies, implicit subscription, implicit propagation: What I initially proposed when I filed this bug.
  • Implicit dependencies, implicit subscription, implicit propagation: What I'm considering about switching to.

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.

  • Explicit linkage means you can create headless tokens. This could useful in a limited number of circumstances where signals are returned rather than passed as arguments as well as more advanced use cases where you need to terminate something where the logic takes it elsewhere, but I've not come across this use case personally where it couldn't be replaced with implicit linkage + subscription. I'm also not aware of a good use case for literally returning signals.
  • Implicit linkage means you don't need to manually repeat yourself when linking parent signals. This is a bit more restrictive in that you can't subscribe to multiple signals and you have to wire explicit subscriptions to multiple sources, but it prevents you from being able to create an entirely headless token.

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.

  • 90% implicit wiring, propagation, and subscription makes cancellation very low-cost and usable even in perf-sensitive contexts.
    • Cancellation means invoking blocks and cancelling dependent cancel tokens. You can make the representation really low-overhead by representing child token sources with nullptrs for the "block" and the token itself for the "context".
    • When no cancel token exists in a particular context, the block's closure need not be allocated at all. This cannot occur without implicit subscription and propagation.
    • Implicit wiring means you're not wasting stack space by having cancellation, only when you're swapping tokens out for child tokens. It's zero cost if you're not using them, and it's zero cost if you're delegating to a function that needs to subscribe to cancellation.
    • Sharply resource-constrained environments like microcontrollers can use this and actually use this in their APIs. Each token costs one object to allocate and two extra pointers if it's a child token, an anonymous function to run, two pointers + 1 closure per allocated cancellation callback, a pointer per call stack, and a small function call of invocation overhead.
    • The reduced need for explicit composition and subscription means there's a lot less being allocated on the heap and collected from the heap. It's way lower cost and more efficient.
    • Minifiers can follow the initial call graph and eliminate do cancel blocks it knows can't be called.
  • The implicit propagation means adding cancellation support could very well be a semver-minor change and require zero code changes in the caller. In fact, most additions of this would mean adding cancellation support would require very few code changes in practice. It's just as simple as adding a few code blocks in various places.
  • The implicit subscription means you don't have to think about the cancellation signal when you're wanting to do something on cancellation. It looks like "on cancel, do this", not "when I'm notified by this cancellation signal, do this", and it's a lot more intuitive.
  • The implicit linkage with the parent signal might seem unhelpful, but it makes it impossible by design to forget to link a token, preventing a very large class of resource leaks on cancellation. Now, you're just doing the right thing without even trying, and that's always a good thing to be able to do. (It's also why people like React Hooks so much - it's harder to leak resources and keep outdated state around.)
  • Restricting linkage to only a single parent signal makes cancellation a proper tree, so it's simpler to understand, easier to explain, and just all around more intuitive. You can just intuitively know that the child will get cancelled when the parent does, and so you might even depend on it without realizing it. A lot of developers resist the idea of making high-level things implicit, but I feel this would be better as implicit for the same reason React decided to make their hooks reliant on global state instead of coroutines and similar.

@dead-claudia
Copy link
Author

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.)

  • To observe cancellation in an observable's initializer callback, you don't return a callback, but use a do cancel { ... } inside the callback itself.
  • Inside observables' subscribe method, an internal do cancel would just set the observer to no longer emit, a very cheap operation.
  • Inside promises' then method, an internal do cancel would remove the subscription without cancelling the promise. A conceptual observable Subject would do similar.

@simonbuchan
Copy link

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.

@dead-claudia
Copy link
Author

@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.)

@simonbuchan
Copy link

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?

@dead-claudia
Copy link
Author

@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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants