Assert triggered in std.Io.Task.start

I’ve been consistently running into an assert in the standard library. I’ve been trying to figure out what is causing it, and I have been unable to reduce it to a small reproduction so I can write up a bug report on the Ziglang repository.

Stack trace:

thread 588214 panic: reached unreachable code
/home/southporter/.local/share/mise/installs/zig/0.16.0/lib/std/debug.zig
:420:14: 0x1399fe9 in assert (std.zig)
    if (!ok) unreachable; // assertion failure
             ^
/home/southporter/.local/share/mise/installs/zig/0.16.0/lib/std/Io/Threaded.zig:560:19: 0x150d23c in start (std.zig)
            assert(old_status.num_running > 0);
                  ^
/home/southporter/.local/share/mise/installs/zig/0.16.0/lib/std/Io/Threaded.zig:1797:29: 0x150b663 in worker (std.zig)
            runnable.startFn(runnable, &thread, t);
                            ^
/home/southporter/.local/share/mise/installs/zig/0.16.0/lib/std/Thread.zig:422:13: 0x150b215 in callFn__anon_27338 (std.zig)
            @call(.auto, f, args);
            ^
/home/southporter/.local/share/mise/installs/zig/0.16.0/lib/std/Thread.zig:752:30: 0x150b009 in entryFn (std.zig)
                return callFn(f, args_ptr.*);
                             ^
???:?:?: 0x7f455d46bc18 in start_thread (/lib64/libc.so.6)
???:?:?: 0x7f455d4ef5cb in __clone3 (/lib64/libc.so.6)

It happens fairly consistently in the following code:

pub fn load(image: *Image, group: *std.Io.Group, client: *std.http.Client) void {
    switch (image.state) {
        .unloaded => |kind| {
            if (kind != .init) return;
            image.state.unloaded = .in_flight;
            // This line _seems_ to be the culprit
            group.concurrent(client.io, fetch, .{ image, client }) catch |err| {
                log.warn("Image loading failed: {t}", .{err});
                image.state.unloaded = .err;
            };
        },
        .downloaded => {},
    }
}

Full codebase file: https://codeberg.org/Southporter/hush/src/commit/931dba033b5285f2fb4421dfc9cbe5a1f2585bb1/src/Image.zig#L93

I looked into the std.Io.Task code for the start, and it seems like this is comming back with a old_status.num_running == 0:

            const old_status = group.status().fetchSub(.{
                .num_running = 1,
                .have_awaiter = false,
                .canceled = false,
            }, .acq_rel); // acquire `group.awaiter()`, release task results
            assert(old_status.num_running > 0);

It seems like I am somehow getting into a state where there are 0 tasks running and this is somehow unexpected.

It’s also possible I’m using it wrong.

Any insights into how to reduce to a small reproduction this would be helpful.

Sorry for not just following the link, but can you talk a little about what this group represents in your code? I ask because the design is kind of confusing: from the context you gave, nobody appears to be waiting for fetch to finish, although arguably that should be the job of a function named load.

The design is to spawn the fetch to an Io.Group and let it complete on it’s own. Nothing waits for it, it updates the state of the Image and then the UI reads that updated state when it happens.
Basic flow is:

std.http.Client.fetch the full html page. This is a sync call from the main thread.
Parse out the pieces including Images.
When an image placeholder is clicked, spawn a concurrent task that does the image loading.

It’s more of a fire-and-forget model. From the std.Io.Group docs:

The resources associated with each task are guaranteed to be released when the individual task returns, as opposed to when the whole group completes or is awaited. For this reason, it is not a resource leak to have a long-lived group which concurrent tasks are repeatedly added to.

So it seems like I can add them to the group, let them run to completion, and I don’t have to await() them individually, but I can cancel the group to terminate downloads when a page change happens or something like that.

It’s hard to tell from the code linked exactly, but I’d advise trying to catch the callsite, or minify the repro.

The assert is checking if num_running > 0 which seems to not sanely happen in the group’s normal code paths, so indicates potential re-use, incorrect copy, use after free, or something similar. Check whether your group or Page aren’t being destroyed, moved, or accessed from multiple threads.

I’m trying to minify the repro, but haven’t been able to get it to trigger in any predictable way other than running my full application.

The std.Io.Group is stable. I make sure to cancel the group whenever I tear down a page.

Can you ellaborate? I’m not sure what you mean here?