Stream: ideas

Topic: task cancellation


view this post on Zulip Richard Feldman (Feb 07 2023 at 04:22):

I wrote up a design for Task cancellation - feedback welcome! https://docs.google.com/document/d/1KG1203CGR4lPA62bOuCjiUNmH-tGlM1QuWa_afSaAVc/edit?usp=sharing

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:51):

Taking a step back for a moment - is "native" cancellation actually something we need?

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:52):

When doing long tasks, I agree that the user is going to want a way to cancel them - but that's not the only requirement for "good" UX (IMO): they're also going to want to be able to pause and resume - and, ideally, be able to close + re-open the app and have it pick up where it left off.

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:53):

With that in mind, it seems obvious to me the "right" way to implement this on the app side is to break up long tasks into smaller items, each of which takes a "reasonable" amount of time - probably no more than a few seconds.

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:54):

In the case of a file upload, that'd mean breaking it into blocks and individually sending those to the server. The server can ack the blocks individually, and store them for some configurable amount of time (say, 24 hours) in case the user cancels/resumes.

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:55):

That of course assumes you have control over the protocol to the server

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:56):

If you don't, you can still break the upload task into individual blocks. You won't be able to natively resume, but you can stop the upload at any arbitrary block boundary.

view this post on Zulip Joshua Warner (Feb 07 2023 at 04:56):

The question in my mind would be - can we make _that_ design cleanly expressible in Roc, without a bunch of boilerplate?

view this post on Zulip Luke Boswell (Feb 07 2023 at 07:11):

I think this looks good. I thought I might put it into an example to see if I understand it correctly. Would something like the below align with the design?

doNothing : Task {} []
makeSlowHttpReqest : Task {} []

update : Model, Event -> (Model, Task {} [])
update = \model, event ->
    when event is
        KeyPressed code ->
            when code is
                Enter ->
                    # Build the http request task
                    (task, cancelToken) = allowCancel makeSlowHttpReqest

                    ({model & cancelLast : cancelToken}, task)

                Escape ->
                    # Cancel the last action, slow http request
                    (model, model.cancelLast)

                _ ->
                    (model, doNothing)
        _ ->
            (model, doNothing)

view this post on Zulip Richard Feldman (Feb 07 2023 at 12:52):

@Luke Boswell I think in that example it would need to call Task.cancel on cancelLast:

# Cancel the last action, slow http request
(model, Task.cancel model.cancelLast)

view this post on Zulip Richard Feldman (Feb 07 2023 at 12:53):

but in general yeah!

view this post on Zulip Richard Feldman (Feb 07 2023 at 12:56):

Joshua Warner said:

The question in my mind would be - can we make _that_ design cleanly expressible in Roc, without a bunch of boilerplate?

this is a great question! I think the "manage the chunks yourself" option is definitely a necessary API (I have a separate design in mind for that) but I think it would definitely be significantly more painful than using something like Task.cancel (and now that you mention it, I think we could have a Task.pause and Task.resume that worked similarly, although I hadn't considered that use case before!)

view this post on Zulip Richard Feldman (Feb 07 2023 at 12:58):

I think this is especially true if you want to cancel some tasks that are chained together, e.g. you have a sequence of tasks that each reads from a file, then does some processing, then writes to another file, and you want to cancel that whole batch of operations - I think that would be really painful to do without something like Task.cancel and I'm not sure how it could be fixed by a "manage the chunks yourself" API

view this post on Zulip Asier Elorz (he/him) (Feb 07 2023 at 16:42):

Interestingly, this mutation can safely be done non-atomically, because it will only ever be either read or else changed from the default of NotCanceled to Canceled, and if you're reading it from another thread to decide whether it has been canceled, there's already an unavoidable a race condition that you have to account for, which is simply that the other thread hasn't attempted to cancel it yet.

If the cancellation is happening in a different thread to the one checking whether the task has been cancelled, I think you need at least relaxed atomics. Although you may get away with it in an architecture where integer loads and stores are already atomic like x86 or ARM if the compiler does not optimize anything away in surprising ways. Otherwise, there is something that I am not understanding about this paragraph.

view this post on Zulip Richard Feldman (Feb 07 2023 at 16:52):

so let's say it's not atomic - what's the specific situation where a bad thing happens?

view this post on Zulip Richard Feldman (Feb 07 2023 at 16:52):

(and what is the specific bad thing that happens?)

view this post on Zulip Joshua Warner (Feb 07 2023 at 18:48):

Good point about cancelling composed tasks

view this post on Zulip Brendan Hansknecht (Feb 07 2023 at 19:07):

There's an argument that this should actually tell you whether the cancellation succeeded or failed (e.g. because the task had already finished) ... which seems undesirable considering you can always implement that behavior in userspace yourself (e.g. after the task has finished normally, have it set a flag indicating that it finished, and then check that flag after Task.cancel completes to see if it was set).

Unless I am missing something, Roc cannot set a global flag or tag of this nature. Only the platform could. I would assume that it is very common for a task to be cancelled on another thread. Cause the current thread is likely frozen waiting on the task to complete. This means that only if the platform expose some sort of primitive message passing or shared variables can this information actually be shared in Roc userland. I think this would be complex per platform coordination if done in userland, vs a simple atomic that should have little to no contention and is rarely accessed if done by the platform.

view this post on Zulip Brendan Hansknecht (Feb 07 2023 at 19:16):

One way to implement this is to define CancelToken := Box [Running, Canceled, Finished]

I don't think by your definition we can have a finished state. As you said, a task can be run multiple times. For example I could write a task for printing the timestamp out before I add the rest of my log message and theoretically run that same task thousands of times.

view this post on Zulip Brendan Hansknecht (Feb 07 2023 at 19:16):

So Either we need a list of task states for it, or we just have to have canceled and not cancelled such that all instances of the task can agree.

view this post on Zulip Brendan Hansknecht (Feb 07 2023 at 19:31):

so let's say it's not atomic - what's the specific situation where a bad thing happens?

Assuming a token is just Box [Running, Canceled, Finished]. There is the case where it is being set by two threads at once. The cancelling thread and the finishing thread. Suddenly, on one thread you are running cancellation code assuming the cancellation was done properly, and on another thread you are running a continuation assuming the task finished successfully.

view this post on Zulip Richard Feldman (Feb 08 2023 at 00:31):

Brendan Hansknecht said:

One way to implement this is to define CancelToken := Box [Running, Canceled, Finished]

I don't think by your definition we can have a finished state. As you said, a task can be run multiple times. [...]

So Either we need a list of task states for it, or we just have to have canceled and not cancelled such that all instances of the task can agree.

yup, good catch!

view this post on Zulip Richard Feldman (Feb 08 2023 at 00:57):

Brendan Hansknecht said:

so let's say it's not atomic - what's the specific situation where a bad thing happens?

Assuming a token is just Box [Running, Canceled, Finished]. There is the case where it is being set by two threads at once. The cancelling thread and the finishing thread. Suddenly, on one thread you are running cancellation code assuming the cancellation was done properly, and on another thread you are running a continuation assuming the task finished successfully.

so let's say it's Box [Canceled, NotCanceled] (per your other comment) - then is there a problem? Because then only the canceling thread attempts to mutate it

view this post on Zulip Richard Feldman (Feb 08 2023 at 00:58):

(Box [Canceled, NotCanceled] is actually the original design I had, then I thought "finished" would be useful and edited part of the doc to include that without realizing the implications elsewhere :sweat_smile:)

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:22):

Yeah, should be safe assuming canceling is only one way. Would be equivalent to relaxed atomic ordering. So as long as we are fine with relaxed and not strict atomic ordering(which is likely) we should be fine.

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:27):

I guess the main issue (which is present here) is what happens if you cancel after an operation has finished.

view this post on Zulip Richard Feldman (Feb 08 2023 at 01:27):

shouldn't have any effect - you change it to Canceled but since the operation is finished, nothing will ever read that

view this post on Zulip Richard Feldman (Feb 08 2023 at 01:27):

and since the only way the cancellation has any effect is by the host reading that value, it becomes a no-op

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:28):

Yes, just more likely to accidentally send your app into an invalid state where you assume you cancelled, but also the finished continuation is running.

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:28):

Since there is no feedback to the cancelling thread.

view this post on Zulip Richard Feldman (Feb 08 2023 at 01:30):

I think that can happen regardless though, right?

view this post on Zulip Richard Feldman (Feb 08 2023 at 01:30):

because the only way for Task.cancel to know whether it was finished is if we have Finished in the tag union, which doesn't work because of multiple tasks

view this post on Zulip Richard Feldman (Feb 08 2023 at 01:30):

that is, the same task being run multiple times

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:33):

Oh, I don't have a solution at the moment, just noting a drawback that I would expect to cause bugs.

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:34):

Something that would be great to design to avoid if possible.

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:35):

For example, we could force tasks to only be run once and just add a way to duplicate a task or simply force repeated instantiation of task. Then atomic operations could lead to a cleaner api for the end users.

view this post on Zulip Brendan Hansknecht (Feb 08 2023 at 01:36):

Not saying that is the correct solution, but I think it is a tradeoff in api we should consider. Potentially a single atomic operation per task could lead to much more user friendly apis.

view this post on Zulip Nikita Mounier (Feb 12 2023 at 18:30):

How does task cancellation propagation work? Eg would tasks spawned by a task that just got cancelled by cancelled too? Or is that all up to the platform, which could implement structured concurrency à la Swift

view this post on Zulip Richard Feldman (Feb 13 2023 at 01:21):

technically it's up to the platform, but I'd expect them all to get canceled yeah

view this post on Zulip choonkeat (Feb 13 2023 at 03:44):

Instead of getting the CancelToken back

(task, token) = Task.allowCancel ...
# call `cancel token` where needed

If we provide the CancelToken

task = Task.allowCancel token ...
# call `cancel token` where needed

Then we get to compose and control the cancelation dependency tree: useful when we want to write function that add retry / cancelation policies. Ideally, token is just value we can create without a Task at hand.

Disclaimer: I'm modelling after the happy parts of Go's context

view this post on Zulip Brendan Hansknecht (Feb 13 2023 at 03:57):

Richard Feldman said:

technically it's up to the platform, but I'd expect them all to get canceled yeah

So you expect the platform to implicitly track all tasks and which where submitted by which other tasks. Kinda in a dependency tree so that it can handle cancelation?

view this post on Zulip choonkeat (Feb 13 2023 at 04:01):

actually more ideal if Roc app can dictate cancelation dependency, without burdening platform / requiring switching platform.

e.g. say i have small general purpose platform: just trap signals and let Roc deal with it (Roc app wants to shutdown gracefully on SIGUSR1)

i'd like to be able to define something like

view this post on Zulip Brendan Hansknecht (Feb 13 2023 at 04:08):

Yeah, with the solution you referenced that relates to go context, that would be possible.

view this post on Zulip Brendan Hansknecht (Feb 13 2023 at 04:10):

Overall, I feel like task cancel may not make sense in Roc builtin task type. I think that it is too platform specific and not really something that can generically be assumed across all platforms. I think it will be a burden on many simpler platforms if it is built into a task type that is in the standard library. That said, there is clearly a need for more complex apps for at least some of their tasks. Though likely not all as many tasks will essentially have immediate returns.

view this post on Zulip Brendan Hansknecht (Feb 13 2023 at 04:13):

As a simple note, any platform that is single threaded and synchronous can not have task cancellation at all. There will be no Roc code running to start a cancellation.

view this post on Zulip choonkeat (Feb 13 2023 at 06:34):

adding to the conundrum, one unhappy part of Go's context is that it was introduced later. 7 years on, there are still a lot of code (and new code!) that uses the non-context version of functions, e.g. http.NewRequest vs http.NewRequestWithContext, http.Get uses http.NewRequest underneath, all of which chips away at the benefit it gives. so it feels like there's reasons to get this in place upfront.


Last updated: Jun 16 2026 at 16:19 UTC