`std.Io` semantics around process waiting

Hi! I’ve recently been implementing process waiting in my interpreter, and I think the current Threaded implementation of wait leaks tasks when canceling. Here’s a reproducer:

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

fn task(io: std.Io) void {
    // 1. Spawn process
    var child = std.process.spawn(io, .{
        .argv = &.{ "sleep", "60" },
    }) catch unreachable;
    defer child.kill(io); // 3. This becomes a no-op, since `child.id` was cleared.

    // 2. This `wait` call will be canceled by `main`. This will clear `child.id`,
    // but will _not_ kill `child`.
    _ = child.wait(io) catch |err| switch (err) {
        error.Canceled => std.debug.print("Canceled\n", .{}),
        else => {},
    };
}

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

    var spawned = try io.concurrent(task, .{io});
    spawned.cancel(io);
    spawned.await(io);

    // Give the task a chance to clean up.
    try io.sleep(.fromMilliseconds(500), .awake);

    // Print live children.
    var dir = try Io.Dir.cwd().openDir(io, "/proc/self/task", .{ .iterate = true });
    defer dir.close(io);
    var it = dir.iterate();
    while (try it.next(io)) |entry| {
        var buf: [4096]u8 = undefined;
        var path: [64]u8 = undefined;
        const p = try std.fmt.bufPrint(&path, "{s}/children", .{entry.name});
        std.debug.print("thread {s} has processes: {s}\n", .{ entry.name, try dir.readFile(io, p, &buf) });
    }
}

When I run this reproducer, I get:

Canceled
thread 145321 has processes: 
thread 145337 has processes: 145338

So there’s still a process hanging around, even though I used child.kill() for cleanup.

The issue is here (in Threaded.zig):

fn childCleanupPosix(child: *process.Child) void {
    if (child.stdin) |stdin| {
        closeFd(stdin.handle);
        child.stdin = null;
    }
    if (child.stdout) |stdout| {
        closeFd(stdout.handle);
        child.stdout = null;
    }
    if (child.stderr) |stderr| {
        closeFd(stderr.handle);
        child.stderr = null;
    }
    child.id = null;
}

fn childWaitPosix(child: *process.Child) process.Child.WaitError!process.Child.Term {
    // Closes the child's fds, clears `id`, but does _not_ kill the task.
    defer childCleanupPosix(child);
    
    const pid = child.id.?;

    var ru: posix.rusage = undefined;
    const ru_ptr = if (child.request_resource_usage_statistics) &ru else null;

    if (have_wait4) {
        var status: if (builtin.link_libc) c_int else u32 = undefined;
        const syscall: Syscall = try .start();
        while (true) switch (posix.errno(posix.system.wait4(pid, &status, 0, ru_ptr))) {
            .SUCCESS => {
                syscall.finish();
                if (ru_ptr) |p| child.resource_usage_statistics.rusage = p.*;
                return statusToTerm(@bitCast(status));
            },
            .INTR => {
                // returns error.Canceled when canceled, but also doesn't kill the process.
                try syscall.checkCancel();
                continue;
            },
            .CHILD => |err| return syscall.errnoBug(err),
            else => |err| return syscall.unexpectedErrno(err),
        };
    }
    // ...
}

The reason I have this as a forum post instead of a bug report is that these are the semantics that are already documented for process.Child.id: “After wait or kill is called, this becomes null.”

So, should wait kill the process if wait gets canceled? Or should wait not clear id in the case of cancelation? Or perhaps something else?

2 Likes

I think wait should not clear the resources on cancel, so errdefer child.kill(); _ = try child.wait(io); can be the right pattern for most common cases. This still leaves options for other scenarios e.g. you generally want to process to finish, even when the caller was canceled.

2 Likes

I was able to reproduce the reported behavior in a separate test, although I have not verified it against current Zig master.

My test environment was Ubuntu 22.04.5 under WSL2, Zig 0.16.0 snap revision 16117, using the Threaded backend in Debug mode with libc linked.

The sequence I observed was:

  1. Spawn a child process.
  2. Confirm with strace that execution had reached wait4.
  3. Cancel the waiting task.
  4. Child.wait returned error.Canceled.
  5. child.id had become null.
  6. A subsequent Child.kill therefore did not terminate the process.

As an additional check, I kept the PID separately in the test harness and called an independent waitpid(pid, ..., WNOHANG) after the cancellation. It returned 0, meaning that, at least at that point, the child was still running rather than already exited and reaped. I repeated this five additional times and observed the same result.

As controls, normal waiting behaved as expected, and cancellation after the child had already exited did not leave the same state. So the behavior appears specifically dependent on cancellation occurring while the wait is still in progress.

For cleanup, my test harness used the separately saved PID to send SIGTERM and then reaped the process with waitpid. That was only test cleanup, not a suggestion for how users of std.process.Child should normally handle this.

I think there are two separate questions here.

First, there is the observable behavior: on this Zig build, canceling wait can leave the child process alive while clearing child.id, which prevents the caller from subsequently using Child.kill for cleanup. I can confirm that part.

Second, there is the intended API contract. The experiment alone does not establish whether this should be considered a bug, whether cancellation of wait should kill and reap the process, or whether wait should instead preserve the child handle/state on error.Canceled.

My intuition is similar to @xash’s suggestion: preserving the child state on cancellation would allow patterns such as

errdefer child.kill(io) catch {};
_ = try child.wait(io);

while still allowing callers to decide whether cancellation of the waiting task should also terminate the child process.

But that is ultimately a question about the intended std.Io / process.Child cancellation semantics rather than something the reproducer alone can determine.

It would also be useful to know the original reporter’s Zig version/commit, OS, and Io backend, since my reproduction was not performed against current master.

I tested it with Zig 0.16.0, but the problematic line is still in the master branch: https://codeberg.org/ziglang/zig/src/commit/d037c6c19d2021001ecf20884a4219aec88f151a/lib/std/Io/Threaded.zig#L15534. I’m using Linux 7.2.4-arch1-2, but that shouldn’t make a difference in this case since the issue is in the unclear contract with the caller, the reproducer was mainly a code snippet to demonstrate the issue.

I’m not sure why you’re asking what Io backend I used, did the reproducer not show that? If you did use an LLM to reproduce this, you might want to be aware of Ziggit’s AI policy: FAQ - Ziggit