跳到正文
Hacker News· ingve·· 5 天前AI 评分22

修补 Zig 标准库:绕过 0.17.0 的 connectUnix 缺陷

Patching Zig's Std

AI 导读

Zig 0.17.0 的 std.http.Client.connectUnix 存在编译错误,作者通过复制 std 源码、用 git 追踪改动并生成 patch 文件,借助 zig-patch 在构建时打补丁修复。

正文 · AI 翻译

Zig 0.17.0 发布时带着一个损坏的 std.http.Client.connectUnix。修复很小,但即使我今天就把它提交到上游,最早也要到 0.18.0 才能发布,而 Zig 版本之间的间隔通常是 6 到 9 个月。这就是你坚持使用稳定版本而不是跟随 master 所接受的代价。好吧,我不喜欢这个代价。我想要我的蛋糕(稳定版本)并且也要吃掉它(现在就修复 bug),所以让我们看看怎么做。

让我们做一个需要现在已损坏的 connectUnix 函数的例子。Docker 通过 unix socket /var/run/docker.sock 提供 HTTP API,所以让我们为它写一个客户端。cd 到一个空目录,然后运行 zig 0.17.0 init --minimal。现在把那个 build.zig 扔掉,换成这个漂亮的东西:

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "dockerver",
        .root_module = b.createModule(.{
            .root_source_file = b.path("dockerver.zig"),
            .target = b.standardTargetOptions(.{}),
        }),
    });
    b.installArtifact(exe);
}

const std = @import("std");

添加 dockerver.zig:

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    var client: std.http.Client = .{ .allocator = init.gpa, .io = io };
    defer client.deinit();

    const connection = try client.connectUnix("/var/run/docker.sock");
    var request = try client.request(.GET, try .parse("http://localhost/version"), .{
        .connection = connection,
    });
    defer request.deinit();
    request.sendBodiless() catch return connection.stream_writer.err.?;
    var response = request.receiveHead(&.{}) catch |err| switch (err) {
        error.ReadFailed => return connection.getReadError().?,
        error.WriteFailed => return connection.stream_writer.err.?,
        else => |e| return e,
    };

    var stdout_buffer: [4096]u8 = undefined;
    var stdout = std.Io.File.stdout().writerStreaming(io, &stdout_buffer);
    _ = response.reader(&.{}).streamRemaining(&stdout.interface) catch |err| switch (err) {
        error.ReadFailed => return response.bodyErr() orelse connection.getReadError().?,
        error.WriteFailed => return stdout.err.?,
    };
    stdout.interface.flush() catch return stdout.err.?;
}

const std = @import("std");

现在构建:

$ zig build
.../lib/std/http/Client.zig:1485:58: error: root source file struct 'posix' has no member named 'SocketError'
pub const ConnectUnixError = Allocator.Error || std.posix.SocketError || error{NameTooLong} || std.posix.ConnectError || Io.Cancelable;
                                                ~~~~~~~~~^~~~~~~~~~~~
referenced by:
    connectUnix: .../lib/std/http/Client.zig:1490:55
    main: dockerver.zig:6:34

通常的变通方法是将损坏的代码复制到你的项目中并在那里修复。但在这种情况下,它需要复制 std 内部内存布局的细节,如果这些在未来版本中发生变化,你会在运行时得到未定义行为,而不是编译错误。对 std 的实际修复相对较小,所以让我们试试那个。首先复制 std 并将其初始化为一个 git 仓库,这样我们就可以跟踪更改:

cp -r "$(zig env | grep std_dir | cut -d'"' -f2)" std
(cd std && git init && git add -A && git commit -m original)

要使用我们的副本,请将其作为名为 std 的导入添加到根模块,即

// put this in the options we pass to createModule
.imports = &.{.{ .name = "std", .module = b.createModule(.{
    .root_source_file = b.path("std/std.zig"),
}) }},

将名为 std 的模块导入根模块会替换整个编译过程中的 std。再次运行 zig build 并确认你得到相同的错误,只是这次错误在我们的 std 副本中。现在使用你典型的修复/重建工作流来消灭这个 bug。剧透警告:这里是修复后的 ConnectUnixError 和 connectUnix,用于 std/http/Client.zig:

pub const ConnectUnixError = Allocator.Error || Io.net.UnixAddress.InitError || Io.net.UnixAddress.ConnectError;

pub fn connectUnix(client: *Client, path: []const u8) ConnectUnixError!*Connection {
    const io = client.io;
    // the pool only compares a host's bytes, it doesn't need to be a valid host name
    const host: HostName = .{ .bytes = path };

    if (try client.connection_pool.findConnection(io, .{
        .host = host,
        .port = 0,
        .protocol = .plain,
    })) |conn| return conn;

    const address: Io.net.UnixAddress = try .init(path);
    var stream = try address.connect(io);
    errdefer stream.close(io);

    const pc = try Connection.Plain.create(client, host, 0, stream);
    errdefer pc.destroy();
    try client.connection_pool.addUsed(io, &pc.connection);
    return &pc.connection;
}

现在它可以构建了,但我们如何与世界分享它呢?我们可以将新的 std 副本添加到源代码控制中,那会起作用。然而,那将是我们添加到仓库中的超过 450,000 行代码。还有另一种方法,一个补丁文件!由于我们将 std 副本初始化为 git 仓库,我们可以用这个命令创建一个补丁文件:

(cd std && git diff -M -C) > connect-unix.patch

zig-patch 在构建过程中应用补丁文件。将其添加为依赖项:

zig fetch --save git+https://github.com/marler8997/zig-patch

这是我们的新 build.zig:

pub fn build(b: *std.Build) void {
    const std_patched = b.dependency("patch", .{
        .std_patches = &[_]std.Build.LazyPath{
            b.path("connect-unix.patch"),
        },
    }).module("std");
    const exe = b.addExecutable(.{
        .name = "dockerver",
        .root_module = b.createModule(.{
            .root_source_file = b.path("dockerver.zig"),
            .target = b.standardTargetOptions(.{}),
            .imports = &.{.{ .name = "std", .module = std_patched }},
        }),
    });
    b.installArtifact(exe);
}

const std = @import("std");

现在你可以删除你的 std 副本,你的工具仍然可以构建和运行:

$ zig build && ./zig-out/bin/dockerver
{"Version":"28.5.1","ApiVersion":"1.51"}

修复是一个 60 行的文件,任何人都可以阅读,当 Zig 版本包含它时,你删除补丁及其在 build.zig 中的行。应用它也不会减慢你的构建速度。zig-patch 作为正常的构建步骤运行,所以 Zig 会缓存打过补丁的 std,并且只在补丁或 Zig 版本更改时重做。像复制变通方法一样,补丁依赖于 std 的私有细节,但当它们改变时,补丁会在构建时而不是运行时失败:要么它的块不再匹配,要么它调用的私有代码不再编译。

堆叠补丁

回顾一下 build.zig 如何创建打过补丁的 std:

const std_patched = b.dependency("patch", .{
    .std_patches = &[_]std.Build.LazyPath{
        b.path("connect-unix.patch"),
    },
}).module("std");

注意复数形式的 std_patches。zig-patch 支持多个补丁。你可以维护自己的 std 补丁,或者包含其他项目的补丁。事实上,有一个项目你今天就可以添加,它没有模块也没有库。它只是一个 std 补丁。将其获取到我们的 Docker 项目中:

zig fetch --save git+https://github.com/marler8997/zig-staticio

现在我们可以将该补丁添加到我们的列表中:

        .std_patches = &[_]std.Build.LazyPath{
            b.path("connect-unix.patch"),
            b.dependency("staticio", .{}).namedLazyPath("std.patch"),
        },

打过补丁的 std 在你选择加入之前的行为与原始版本完全相同,所以 Docker 程序可以不变地构建和运行。那么一旦你选择加入,staticio 会做什么?

staticio

std.Io 是每个 I/O 操作都要经过的接口。像 std.mem.Allocator 一样,它是一个 userdata 指针加上一个指向 vtable 的指针,而 vtable 包含大约一百个函数。要编写你自己的 Io,你需要填写所有这些函数。大多数程序只使用少数几个,所以你实现那些并用 error.Unimplemented 存根其余的,并希望自己猜对了。当你猜错时,你会在运行时发现。

staticio 给 std.Options 添加了一个 io_vtable 字段,让根模块可以在编译时选择它的 Io 实现。这样一来,io.vtable 不再是运行时指针,而变成了编译期类型,因此每次 io.vtable.X(...) 都是直接调用,Zig 只会编译你的程序能够到达的函数。

HTTP 客户端牵扯到的东西远比你想象的多(staticio 让我看到,即使一个客户端只通过 unix socket 通信,它也会加载 TLS 证书包),所以让我们用一个 hello world 来保持精简。向 build.zig 添加第二个可执行文件,使用同一个打过补丁的 std:

    const hello = b.addExecutable(.{
        .name = "hello",
        .root_module = b.createModule(.{
            .root_source_file = b.path("hello.zig"),
            .target = b.graph.host,
            .optimize = .small,
            .imports = &.{.{ .name = "std", .module = std_patched }},
        }),
    });
    b.installArtifact(hello);

我们用 .optimize = .small 来构建它,以避开 panic handler。在其他模式下它是可达的,而使用自定义的 Io 时,它需要一个 crashHandler 函数,外加一个单独的 std_options_debug_io 声明来打印堆栈跟踪。这比 hello world 需要的多得多。

然后添加一个普通的 hello.zig:

pub fn main(init: std.process.Init) !void {
    var stdout = std.Io.File.stdout().writerStreaming(init.io, &.{});
    stdout.interface.writeAll("Hello, World!\n") catch return stdout.err.?;
}

const std = @import("std");
$ zig build && ./zig-out/bin/hello
Hello, World!

我们还没有选择启用,所以这和未打补丁的 std 得到的是同一个程序,在 Linux 上二进制文件为 145 KB。现在通过向 hello.zig 添加一行,选择启用 std 自带的 Io 实现来进行直接调用:

pub const std_options: std.Options = .{ .io_vtable = .threaded };

同样的程序,同样的输出,但只有 std.Io.Threaded 中它能够到达的部分会被编译进来,二进制文件缩小到 31 KB。

现在到了有趣的部分。不用 .threaded,我们可以用 .{ .custom = MyIo } 选择自己的实现。MyIo 只需要程序能够到达的函数,如果缺少某个函数,你会得到编译错误而不是运行时错误。让我们从一个空的开始:

pub const std_options: std.Options = .{ .io_vtable = .{ .custom = MyIo } };

const MyIo = struct {};

pub fn main() u8 {
    const io: std.Io = .{ .userdata = null };
    var stdout = std.Io.File.stdout().writerStreaming(io, &.{});
    stdout.interface.writeAll("Hello, World!\n") catch return 1;
    return 0;
}

const std = @import("std");

main 不再接受 std.process.Init,因为那会设置默认的 std.Io.Threaded,并且它返回 u8 而不是错误,因为返回错误会引入错误跟踪打印。构建后我们得到了待办清单:

$ zig build
Io/File/Writer.zig:101:24: error: struct 'hello.MyIo' has no member named 'fileWritePositional'
Io/File/Writer.zig:148:24: error: struct 'hello.MyIo' has no member named 'fileWriteFilePositional'
Io/File/Writer.zig:181:24: error: struct 'hello.MyIo' has no member named 'fileWriteFileStreaming'
IoStatic.zig:562:21: error: struct 'hello.MyIo' has no member named 'operate'
error: 4 compilation errors

operate 是实际写入发生的地方。另外三个是可达的,因为 File.Writer 在运行时选择它的模式,但我们的 writer 始终是流式的,所以它们永远不会被调用。实现这 4 个,再次构建,编译器又发现了一个仅通过位置路径才变得可达的函数:

Io/File/Writer.zig:218:22: error: struct 'hello.MyIo' has no member named 'fileSeekTo'
            io.vtable.fileSeekTo(io.userdata, w.file, offset) catch |err| {
            ~~~~~~~~~^~~~~~~~~~~
referenced by:
    seekTo: Io/File/Writer.zig:201:25
    sendFilePositional: Io/File/Writer.zig:154:25

加上它,我们就有了程序完整的 Io:

const MyIo = struct {
    pub fn operate(_: ?*anyopaque, op: std.Io.Operation) std.Io.Cancelable!std.Io.Operation.Result {
        switch (op) {
            .file_write_streaming => |w| return .{ .file_write_streaming = write(w.file, w.header, w.data, w.splat) },
            else => @trap(), // hello world only writes to stdout
        }
    }

    fn write(file: std.Io.File, header: []const u8, data: []const []const u8, splat: usize) !usize {
        // a partial write is fine, the writer calls again with the rest
        const bytes = if (header.len > 0) header else if (data.len > 1 or splat > 0) data[0] else return 0;
        const rc = std.os.linux.write(file.handle, bytes.ptr, bytes.len);
        return switch (std.os.linux.errno(rc)) {
            .SUCCESS => rc,
            else => error.Unexpected,
        };
    }

    // the writer's mode is a runtime value, so its positional paths are
    // reachable, but our writer is always streaming
    pub fn fileWritePositional(_: ?*anyopaque, _: std.Io.File, _: []const u8, _: []const []const u8, _: usize, _: u64) std.Io.File.WritePositionalError!usize {
        @trap();
    }
    pub fn fileSeekTo(_: ?*anyopaque, _: std.Io.File, _: u64) std.Io.File.SeekError!void {
        @trap();
    }
    pub fn fileWriteFileStreaming(_: ?*anyopaque, _: std.Io.File, _: []const u8, _: *std.Io.File.Reader, _: std.Io.Limit) std.Io.File.Writer.WriteFileError!usize {
        @trap();
    }
    pub fn fileWriteFilePositional(_: ?*anyopaque, _: std.Io.File, _: []const u8, _: *std.Io.File.Reader, _: std.Io.Limit, _: u64) std.Io.File.WriteFilePositionalError!usize {
        @trap();
    }
};
$ zig build && ./zig-out/bin/hello
Hello, World!

其他 94 个函数没有桩,因为程序中没有东西能到达它们。我们写的这 4 个桩也不是猜测。编译器明确告诉了我们哪些函数是可达的,而且我们知道我们的 writer 从不走位置路径。它们使用 @trap() 以防我们判断错误。在 release 构建中,unreachable 会是未定义行为,而 @panic 会把 panic handler 重新拉进来。如果程序发生变化并开始到达一个新函数,那又会是编译错误。

额外的好处是,二进制文件现在只有 4.9 KB,相比使用 .threaded 时的 31 KB 和不用 staticio 时的 145 KB 都小了很多。

问题在于每个程序只能有一个 Io。你不能在同一个二进制文件中混用自定义的和 Threaded。测试也仍然使用运行时 vtable,因为测试运行器会设置自己的 std_options。

为什么要用补丁文件?

Zig 的构建系统本身就是一个 Zig 程序,所以它已经可以在构建时以任何你喜欢的方式修改代码。这比补丁文件更强大,但也更难理解。补丁文件既是变更的指令,也是变更内容的文档。以 - 开头的行被删除,以 + 开头的行被添加。你不必追踪一个程序来弄清楚它对 std 做了什么,任何审阅过 pull request 的人都能读懂它。

这不是新想法。Linux 发行版几十年来一直在上游软件包之上携带补丁,Nix 软件包有一个 patches 列表。zig-patch 把这一点带到了 Zig 构建系统。它用 Zig 编写,作为构建的一部分运行,所以不需要安装 patch 或 git。它不支持文件删除、模式更改或二进制补丁,这些我在 std 上都不需要。

zig-patch 也会忽略行号。每个 hunk 的原始行必须在被补丁的文件中恰好出现一次,因此如果 std 在无关位置发生了变化,补丁仍然可以应用。如果补丁所针对的代码发生了变化,它就会失败,并指出不再匹配的那个 hunk:

bad.patch:6: patching 'x.txt' failed with HunkNotFound

当你升级 Zig,而该版本已经包含你的修复时,这个失败就是提示你删除该补丁的信号。

zig-patch 也不限于 std。它还可以给任何目录打补丁。假设你依赖的一个 C 库有一个修复尚未进入正式发布:

const patched_src = b.dependency("patch", .{
    .source = b.dependency("somelib", .{}).path(""),
    .patches = &[_]std.Build.LazyPath{
        b.path("somelib-fix.patch"),
    },
}).namedLazyPath("patched");

patched_src 是指向已打补丁副本的 LazyPath,因此你可以像使用任何其他目录一样使用它,例如 patched_src.path(b, "src/foo.c")。原始目录保持不变。

试一试

两者都支持 Zig 0.16.0 和 0.17.0。

来源:Hacker News · marler8997.github.io