What are the reasons for exposing errno in std.os.linux syscalls?

I tried writing some code that used Linux-specific syscalls and was surprised that the implementation of these syscalls returned actual usize integers as return codes and not zig error-sets as with most other functions in std. I will try and present my objections, and hope someone will refute them.
I tried looked into it and found this issue which reorganized std.os, and it states:

In Zig-flavored POSIX [what is now std.os], errno is not exposed; instead actual zig error unions and error sets are used. When not linking libc on Linux there will never be an errno variable, because the syscall return code contains the error code in it. However for OS-specific APIs, where that OS requires the use of libc, errno may be exposed, for example as std.os.darwin.errno().

I personally do not understand the reasons for the choice of making std.os.linux syscall return a number instead of an error set, and would like someone to explain to me why it makes sense. It degrades syscall error handling to a C-style error handling style. And so for system-specific syscalls Zig shares the faults of the C error handling system (or at least up to ignoring return values):

// C raw syscalls
int main() {
    int fd = open("does_not_exist/foo.txt", O_CREAT);
    write(fd, "hi", 2);
    close(fd);
    return 0;
}
// Zig raw syscalls
pub fn main() !void {
    var fd: isize = @bitCast(linux.open("does_not_exist/foo.txt", 0, linux.O.CREAT));
    _ = linux.write(@truncate(fd), "hi", 2);
    _ = linux.close(@truncate(fd));
}

(code example from road to zig 1.0)
This style of course half-implicitly ignores any errors returned from these without any try keywords. This leads people who write code which heavily uses OS-specific interfaces to write a wrapper file with error-set wrappers like this one with code that should honestly be in std.
Another problem created which one might notice in the above code is the current interface creates a lot of redundant casting, it would make more sense for std.os.linux.open to return an i32 type with an error set instead of a usize.
A supposedly correct program which at least panics on errors (not even gracefully handles cases) from these functions will look like this:

const std = @import("std");
const linux = @import("std").os.linux;

inline fn panic_on_err(value: usize) void {
    if (linux.getErrno(value) != .SUCCESS) {
        std.debug.panic("There was _some_ error, but to know which enum values to check we must consult manpages! {}", .{linux.getErrno(value)});
    }
}

pub fn main() !void {
    var ret = linux.open("does_not_exist/foo.txt", 0, linux.O.CREAT);
    panic_on_err(ret);

    var fd: i32 = @truncate(@as(isize, @bitCast(ret)));
    ret = linux.write(fd, "hi", 2);
    panic_on_err(ret);

    ret = linux.close(fd);
    panic_on_err(ret);
}

Which, for me feels too unziggy. I would like to understand the reasons for this design which (for me at least) feels like it makes it harder to write code which uses syscalls.

1 Like

I think the interface in std.os.linux is supposed to represent the raw interface without any ziggification.

If you want a better interface, take a look at std.os which for example has std.os.open(), returning a OpenError!fd_t where fd_t is an alias for i32.

I agree that is what it is, but a better interface does not exist for OS-specific syscalls, like mount for example. As I pointed out, that means each programmer needs to rewrite a ziggification for those apis (refer to this).
Maybe the example I chose didn’t reflect my intentions but that is my issue.

This is largely a guess, but I have a feeling that this is a part of it:

That is, std.os.system can vary based on target OS and/or if libc is being linked, and it’s used like this:

So this code could be translating the linux syscall return or it could be translating the libc function return (and getting the actual error using _errno() in the libc case). This setup allows them to share the same implementation.

Relevant getErrno implementations (note that std.os.errno = system.getErrno)

Linux:

C:

2 Likes

That’s a neat observation actually! But even if so - wouldn’t it make sense to use this system errno interface only internally for std? Or even better - abolish it entirely and make the switch-cases that std.os uses and move them into the std.os.foo apis such that std.os.foo.func returns the same error set as std.os.func. Either way this would also imply std.os.foo.os_specific_func should return error sets and not errnos (and even in the current implementation os-specific functions aren’t releated in any manner to std.os and could be replaced, although the heterogeneity would be bad).

From what I gathered the goal of std.os is not to contain a ziggified version of every syscall for every supported operating system out there. Instead it’s main purpose is to serve the standard-libray and the compiler (and maybe also common use-cases outside of that). And I guess mount is just not on that list.

And I think that’s a good thing since it means less maintenance work for the people working on the standard library and compiler.

But it doesn’t mean that each programmer needs to write their own ziggified API.
You can for example make a library for that, which can be reused across different projects. Now that the package manager exists that should be relatively easy.

3 Likes

Why does r keep getting cast back and forth from isize to usize? It would be much nicer to just map the errno value directly to a error instead of each syscall rapper repeating similar and partially overlapping (and you hope identical) transformations in the switch. Have one place where that mapping is done and one error set for all errno values. Then you can expose that for code outside stdlib.

I agree with this, I am not suggesting that we should put mount inside std.os. Instead I am suggesting we make std.os.foo return actual error codes instead of usize.

It doesn’t make sense to me to require language users to install a 3rd party library just for using basic syscalls in a normal zig fashion. The place for these API wrappers should is definitely std, wouldn’t you agree?

I agree that zig should contain API wrappers for basic/common syscalls.
And it does already have that for a lot of syscalls, like for example the posix socket API.
If you feel that there is some important functions missing, then I’d suggest you to go on github and make an issue or pull request there.

1 Like

To conclude this thread, here’s the issue.
Thanks everyone for your feedback!

In the current Zig version (> 0.11), you can and perhaps should use:

const std = @import("std");
const print = std.debug.print;
const posix = std.posix;

pub fn main() !void {
    const rights = 0o755;

    try posix.mkdir("does_not_exist", rights);
    const fd = try posix.open("does_not_exist/foo.txt", .{ .ACCMODE = .WRONLY, .CREAT = true }, rights);
    defer posix.close(fd);

    const ret = try posix.write(fd, "hi");
    print("{d} bytes written\n", .{ret});
}

I think it’s very ziggy. :grinning: