Help understanding error.Canceled

Ok, I’ll admit I’ve been dragging my feet a bit to understand the ‘new’ Io. Today I’ve been diving into it, and in particular I’m trying to understand when to handle error.Canceled, and when it could happen.

From the 0.16.0 release notes Andrew writes:

Only the logic that made the cancelation request can soundly ignore an error.Canceled. Otherwise, there are three ways to handle error.Canceled. In order of most common:

  1. Propagate it.
  2. After receiving it, io.recancel() and then don’t propagate it. This rearms the cancelation request, so that the next check will have a chance to detect and acknowledge the request.
  3. Make it unreachable with io.swapCancelProtection().

And then there is an example:

const std = @import("std");
const Io = std.Io;

test "trivial cancel demo" {
    const io = std.testing.io;

    var file_task = io.async(Io.Dir.openFile, .{ .cwd(), io, "hello.txt", .{} });
    defer if (file_task.cancel(io)) |file| file.close(io) else |_| {};
}

Assumption 1: When it says ‘only logic that made the cancelation request can soundly ignore an error.Canceled’ I read that as you actually have to be handling the result of a call to .cancel(). Is that right?

Continuing on: essentially all Io functions now return an Io.Cancelable error (error.Canceled). This allows them to be used as cancellation points. From the docs of CancelProtection:

In rare cases, it is desirable to completely block cancelation notification, so that a region of code can run uninterrupted before error.Canceled is potentially observed. Therefore, every task has a “cancel protection” state which indicates whether or not Io functions can introduce cancelation points.

To modify a task’s cancel protection state, see swapCancelProtection.

Assumption 2: the point of having Io functions return error.Canceled is so that they can be used as cancelation points, but that if I’m positive that no cancelation is going to happen, i’m free to ignore or even unreachable the error.

For example consider this simple function that reads in a path from the command line and opens that directory.

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const arena = init.arena.allocator();
    const args = init.minimal.args;

    const prog = args.vector[0];
    if (args.vector.len <= 1) usage(io, prog);
    const path = args.vector[2];

    const dir = Io.Dir.openDir(io, path) catch |err| {
        switch(err) {
            error.Canceled => unreachable,
            else => return err,
        }
    };
    _ = dir; // Do something with it later
}

Since the function to open is not called in an async/concurrent context, am I free to mark error.Canceled as unreachable? Or am I missing something. Obviously in this simple example cancelation is impossible, because canceled is never called. What about more complex scenarios. If an Io function is called in a blocking way, can it receive an error.Canceled?

3 Likes

Answering to the best of my knowledge.

I interpret the quoted example in the release notes as more about .cancel(io) usage, not handling the error.Canceled specifically. It does “handle” the possible cancel, but the code has no need for any extra behaviour other than simply ending the test execution.

My understanding is that any context can be canceled, through signals, syscall interruption or otherwise. e.g. ECANCELED being returned. It’s likely system dependant though, but also could easily change in the future. I would not assume and just take the safe path of actually handling that outcome.

1 Like

This is used by Threaded to interrupt blocking syscalls, but it does protect against other causes with its own state flagging if a cancel has actually occurred, if it has not occurred it just re-runs the syscall.

I would expect other implementations to have the same behaviour, it wouldn’t make sense to allow spurious cancellations.

1 Like

I don’t like ‘request’ and ‘receive’ words in cancel flow

set and check will be more understandable

Actually cancel (my understanding) - is simply set of some variable within internal struct.

Cancelation is ‘cooperative

  • io function checks internal state
  • if cancel flag
    • was set
    • and cancelation is not disabled
  • return error.Canceled

It’s trying to say the opposite. In most of your code, you need to propagate errror.Canceled to the caller. The consequence of ignoring the error in one place is your entire task becomes uncancelable, because the error will only be delivered once.

But what you say is also true, you should be handling the result of .cancel() because it might contain resources, like in your example.

Due to the nature of the interface, you should always assume the function can return error.Canceled, unless you explicitly use swapCancelProtection. There is no concept of “in a blocking way”. If you are calling your function from main, nothing can currently cancel it, becaue the interface doesn’t support it, but I wouldn’t count on that to be future-proof and I don’t see a good reason why not just treat all code as cancellable.

That’s not the whole picture, cancellation is the only preemptive-like feature in the whole model. Your code can be parked in read(), waiting on network I/O, and the moment you call cancel(), the I/O operation will be interrupted and the read() call fails with error.Canceled. So it’s not just set and check. The interruption logic is what makes it useful, but also complex, to implement.

3 Likes

just curious - what’s the “physics” of interrupt?

updated: from std.Io.Threaded

    /// Sends a signal to `thread` if it is still blocked in a syscall (i.e. has not yet observed
    /// the cancelation request from `cancelAwaitable`).
................................................................................
fn signalCanceledSyscall(thread: *Thread, t: *Threaded, awaitable: AwaitableId)

.linux => ....std.os.linux.tgkill.......
.windows => ............windows.ntdll.NtCancelSynchronousIoFile............
                       .........NtAlertThread.................
......................................................................

in other places - NtAlertMultipleThreadByThreadId/NtAlertThreadByThreadId

Now I understand meaning of ‘request
and complexity/stability of whole flow :joy: