修补 Zig 标准库:绕过 0.17.0 的 connectUnix 缺陷
Patching Zig's Std
Zig 0.17.0 的 std.http.Client.connectUnix 存在编译错误,作者通过复制 std 源码、用 git 追踪改动并生成 patch 文件,借助 zig-patch 在构建时打补丁修复。
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