I wrote up a design for Task cancellation - feedback welcome! https://docs.google.com/document/d/1KG1203CGR4lPA62bOuCjiUNmH-tGlM1QuWa_afSaAVc/edit?usp=sharing
Taking a step back for a moment - is "native" cancellation actually something we need?
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.
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.
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.
That of course assumes you have control over the protocol to the server
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.
The question in my mind would be - can we make _that_ design cleanly expressible in Roc, without a bunch of boilerplate?
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)
@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)
but in general yeah!
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!)
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
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.
so let's say it's not atomic - what's the specific situation where a bad thing happens?
(and what is the specific bad thing that happens?)
Good point about cancelling composed tasks
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.
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.
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.
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.
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!
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
(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:)
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.
I guess the main issue (which is present here) is what happens if you cancel after an operation has finished.
shouldn't have any effect - you change it to Canceled but since the operation is finished, nothing will ever read that
and since the only way the cancellation has any effect is by the host reading that value, it becomes a no-op
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.
Since there is no feedback to the cancelling thread.
I think that can happen regardless though, right?
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
that is, the same task being run multiple times
Oh, I don't have a solution at the moment, just noting a drawback that I would expect to cause bugs.
Something that would be great to design to avoid if possible.
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.
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.
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
technically it's up to the platform, but I'd expect them all to get canceled yeah
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
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?
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
SIGUSR1, inside the function, calls CancelToken.cancel CancelToken.defaulttoken = CancelToken.withDeadline CancelToken.default in15sSIGUSR1 happens OR DB connection does not succeed in 15s, db connect task failedYeah, with the solution you referenced that relates to go context, that would be possible.
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.
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.
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