Cancel all IO operations with Ctrl+C

I’m working on a CLI program that may run for several of minutes and must gracefully exit if the user interrupts it with Ctrl+C.

The issue that I’m running into is that SIGINT normally just kills the program before any cleanup can happen, and catching it with a no-op signal handler just means that all the syscalls retry after EINTR because checkCancel() has not been informed that it should stop. I assume the handler needs to inform the Io implementation of the cancelation, but I haven’t found a good way to do that.

I looked into std.Io.Threaded to see if it would be possible to make a shallow wrapper around its interface. Unfortunately the internals use Syscall.checkCancel(), rather than something that could be easily overridden.

Right now, the only thing that I can think of that might work is to copy everything groupCancel() depends on, and then strip out the group-specific stuff so that it cancels all the worker threads. I’m leery of doing that though, since I don’t have a good grasp of how the atomics fit together, and it would mean a lot more platform-specific code that I’d need to test and maintain.

Is there a (hopefully relatively simple) solution to this? Ideally something that would work with SetConsoleCtrlHandler on Windows, in addition to handling Posix signals.

Thank you!

2 Likes

An Io.Event can safely be set from a signal handler. If all of your work is in a group, you can have a thread wait on an Io.Select that concurrently awaits the group, and waits on the Event, cancelling the group if the event gets set.

5 Likes

I’m in a similar situation with a long running server. My solution is to cancel the top level group/future/select and then propagate the cancelation.

My understanding of cancel is that it performs a shallow cancelation of a task. Forcing you to propagate the cancelation to sub-tasks. This will lead you to cancel the group/future/select in the same thread its created on.

In practice the code looks something like this:

// In main
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, task_A, .{});
try group.concurrent(io, task_B, .{});
try group.await();

// In task_A
var sub_group: std.Io.Group = .init;
// when the below await gets canceled because taks_A's group got canceled
// this defer will propagate the cancelation
defer sub_group.cancel(io);
try sub_group.concurrent(io, task_A_a, .{});
try sub_group.concurrent(io, task_A_b, .{});
try sub_group.await();

I hope my understanding is correct otherwise I’ve got some bugs to fix :'D

1 Like

but there are things like webservers which assume the only thing that might stop their work is a system interrupt or a crash… (i.e they never cancel themselves) so for their workers, if one fails they won’t bother propagating the error and just “well, I died! return” then log the error. or just make it unreachable
and that would cause frustration when you Ctrl+C and your terminal is like this:

^CGET / 500
encountered error: 'Canceled'
GET / 500
encountered error: 'Canceled'
GET / 500
encountered error: 'Canceled'

I might suggest having a deinitInterrupted function as a solution which just goes and exits gracefully for you (maybe just call future.clacel(io) ot group.cancel(io))

This works, thank you!

Here is a minimal working example for anyone else that had a similar issue.

const std = @import("std");

var io: std.Io = undefined;
var event: std.Io.Event = .unset;

pub fn main(init: std.process.Init) !void {
    io = init.io;

    const action: std.posix.Sigaction = .{
        .handler = .{ .handler = &handler },
        .mask = std.posix.sigemptyset(),
        .flags = 0,
    };
    _ = std.posix.sigaction(std.posix.SIG.INT, &action, null);
    _ = std.posix.sigaction(std.posix.SIG.TERM, &action, null);

    var work: std.Io.Group = .init;
    var results: [2]Result = undefined;
    var select: std.Io.Select(Result) = .init(io, &results);

    defer {
        select.cancelDiscard();
        work.cancel(io);
    }

    try work.concurrent(io, worker, .{});
    try select.concurrent(.done, std.Io.Event.wait, .{ &event, io });
    try select.concurrent(.done, std.Io.Group.await, .{ &work, io });

    _ = try select.await();

    std.debug.print("clean up\n", .{});
}

const Result = union(enum) {
    done: std.Io.Cancelable!void,
};

fn worker() std.Io.Cancelable!void {
    var group: std.Io.Group = .init;
    defer group.cancel(io);

    group.async(io, std.Io.sleep, .{ io, .fromSeconds(4), .awake });

    try group.await(io);
}

fn handler(_: std.posix.SIG) callconv(.c) void {
    event.set(io);
}

@abdullah-b-al’s suggestion was relevant too. I think all three items in main need to be concurrent, otherwise it could block and the signal wouldn’t actually cancel anything. Splitting it out into a worker lets you relax the requirement for concurrency within that worker.

3 Likes

I ended up tinkering with this some more, and I found a solution that I think works better (at least for my use-case). The initial solution does work, and might still be valuable, so I’m just posting this in addition to the other.

The main problem with the original is that it required three units of concurrency in addition to the main thread, and two of them were doing nothing but waiting for something else to happen. The updated solution only requires one unit of concurrency (which does the work) and the main thread is what waits.

const std = @import("std");

var io: std.Io = undefined;
var event: std.Io.Event = .unset;

pub fn main(init: std.process.Init) !void {
    defer std.debug.print("exited\n", .{});

    io = init.io;

    const action: std.posix.Sigaction = .{
        .handler = .{ .handler = &handler },
        .mask = std.posix.sigemptyset(),
        .flags = 0,
    };
    _ = std.posix.sigaction(std.posix.SIG.INT, &action, null);
    _ = std.posix.sigaction(std.posix.SIG.TERM, &action, null);

    var work = try io.concurrent(worker, .{init});
    event.waitUncancelable(io);

    try work.cancel(io);
}

fn worker(_: std.process.Init) !void {
    defer event.set(io);

    var group: std.Io.Group = .init;
    defer group.cancel(io);
    group.async(io, std.Io.sleep, .{ io, .fromSeconds(4), .awake });
    try group.await(io);
}

fn handler(_: std.posix.SIG) callconv(.c) void {
    event.set(io);
}

This works by waiting unconditionally on the event on main, and triggering the event from both the signal handler, and at the end of the worker.

6 Likes