diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 362ec26..981ce0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Zig uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Check code formatting run: zig fmt --check . diff --git a/README.md b/README.md index 41adb02..acf5425 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # memcached.zig -A memcached client library for Zig, built on [zio](https://github.com/lalinsky/zio) for async I/O. Uses the modern [meta protocol](https://docs.memcached.org/protocols/meta/) for efficient communication. +A memcached client library for Zig, built on `std.Io` for async I/O. Uses the modern [meta protocol](https://docs.memcached.org/protocols/meta/) for efficient communication. ## Features -- Async I/O via zio coroutines +- Async I/O via std.Io - Connection pooling per server - Multi-server support with consistent hashing (rendezvous) - Meta protocol (mg, ms, md, ma commands) @@ -15,17 +15,13 @@ A memcached client library for Zig, built on [zio](https://github.com/lalinsky/z ```zig const std = @import("std"); -const zio = @import("zio"); const memcached = @import("memcached"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; - var rt = try zio.Runtime.init(gpa.allocator(), .{}); - defer rt.deinit(); - - var client = try memcached.connect(gpa.allocator(), "localhost:11211"); + var client = try memcached.connect(allocator, io, "localhost:11211"); defer client.deinit(); // Set a value @@ -47,7 +43,7 @@ pub fn main() !void { ## Multi-server ```zig -var client = try memcached.Client.init(gpa.allocator(), .{ +var client = try memcached.Client.init(allocator, io, .{ .servers = &.{ "server1:11211", "server2:11211", diff --git a/build.zig b/build.zig index c488567..4a1685b 100644 --- a/build.zig +++ b/build.zig @@ -4,18 +4,12 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const zio = b.dependency("zio", .{ - .target = target, - .optimize = optimize, - }); - // Library module const mod = b.addModule("memcached", .{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, }); - mod.addImport("zio", zio.module("zio")); // Examples const examples_step = b.step("examples", "Build all examples"); @@ -29,7 +23,6 @@ pub fn build(b: *std.Build) void { }), }); example.root_module.addImport("memcached", mod); - example.root_module.addImport("zio", zio.module("zio")); const install = b.addInstallArtifact(example, .{}); examples_step.dependOn(&install.step); diff --git a/build.zig.zon b/build.zig.zon index 9591e39..f09d188 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,16 +2,11 @@ .name = .memcached, .version = "0.0.1", .fingerprint = 0x2e7f2a7825dfe6f7, - .minimum_zig_version = "0.15.0", + .minimum_zig_version = "0.16.0", .paths = .{ "build.zig", "build.zig.zon", "src", }, - .dependencies = .{ - .zio = .{ - .url = "git+https://github.com/lalinsky/zio?ref=v0.8.2#3ac234eae165177cd75742f922851dc5d373fd12", - .hash = "zio-0.8.2-xHbVVC5jGAAYUMZd_BW5eT-HJb_lf-LCGj4PaLOeEkA2", - }, - }, + .dependencies = .{}, } diff --git a/examples/basic.zig b/examples/basic.zig index c12bd02..20b97c7 100644 --- a/examples/basic.zig +++ b/examples/basic.zig @@ -1,17 +1,12 @@ const std = @import("std"); -const zio = @import("zio"); const memcached = @import("memcached"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - var rt = try zio.Runtime.init(allocator, .{}); - defer rt.deinit(); +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; // Connect - var client = try memcached.connect(allocator, "localhost:11211"); + var client = try memcached.connect(allocator, io, "localhost:11211"); defer client.deinit(); // Version diff --git a/src/Client.zig b/src/Client.zig index 8dbff17..7dec18e 100644 --- a/src/Client.zig +++ b/src/Client.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const zio = @import("zio"); const Allocator = std.mem.Allocator; const Connection = @import("Connection.zig"); const Pool = @import("Pool.zig"); @@ -12,11 +11,12 @@ const log = std.log.scoped(.memcached); const Client = @This(); gpa: Allocator, +io: std.Io, servers: []Server, hasher: Hasher, round_robin: std.atomic.Value(usize), retry_attempts: usize, -retry_interval: zio.Duration, +retry_interval: std.Io.Duration, pub const Options = struct { servers: []const []const u8 = &.{}, @@ -24,11 +24,11 @@ pub const Options = struct { max_idle: usize = 2, read_buffer_size: usize = 4096, write_buffer_size: usize = 4096, - connect_timeout: zio.Timeout = .none, - read_timeout: zio.Timeout = .none, - write_timeout: zio.Timeout = .none, + connect_timeout: std.Io.Timeout = .none, + read_timeout: std.Io.Timeout = .none, + write_timeout: std.Io.Timeout = .none, retry_attempts: usize = 2, - retry_interval: zio.Duration = .zero, + retry_interval: std.Io.Duration = .{ .nanoseconds = 0 }, }; // Re-export types for convenience @@ -37,7 +37,7 @@ pub const GetOpts = Connection.GetOpts; pub const SetOpts = Connection.SetOpts; pub const Error = Connection.Error; -pub fn init(gpa: Allocator, options: Options) !Client { +pub fn init(gpa: Allocator, io: std.Io, options: Options) !Client { if (options.servers.len == 0) return error.NoServers; const servers = try gpa.alloc(Server, options.servers.len); @@ -54,11 +54,12 @@ pub fn init(gpa: Allocator, options: Options) !Client { for (options.servers, 0..) |server_str, i| { const host, const port = parseServer(server_str) orelse return error.InvalidServer; - servers[i] = Server.init(gpa, host, port, pool_opts); + servers[i] = Server.init(gpa, io, host, port, pool_opts); } return .{ .gpa = gpa, + .io = io, .servers = servers, .hasher = options.hasher, .round_robin = std.atomic.Value(usize).init(0), @@ -107,7 +108,7 @@ fn withServer(self: *Client, server: *Server, comptime func: anytype, args: anyt attempts, self.retry_attempts, }); - try zio.sleep(self.retry_interval); + try self.io.sleep(self.retry_interval, .awake); continue; } return err; @@ -128,7 +129,7 @@ fn withServer(self: *Client, server: *Server, comptime func: anytype, args: anyt attempts, self.retry_attempts, }); - try zio.sleep(self.retry_interval); + try self.io.sleep(self.retry_interval, .awake); continue; } return err; @@ -221,8 +222,10 @@ test "parseServer ipv6" { try std.testing.expectEqual(11211, result.?[1]); } +const testing = @import("testing.zig"); + test "Client get/set" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, }); defer client.deinit(); @@ -237,7 +240,7 @@ test "Client get/set" { } test "Client get non-existent returns null" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, }); defer client.deinit(); @@ -249,7 +252,7 @@ test "Client get non-existent returns null" { } test "Client incr/decr" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, }); defer client.deinit(); @@ -264,7 +267,7 @@ test "Client incr/decr" { } test "Client delete" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, }); defer client.deinit(); @@ -278,7 +281,7 @@ test "Client delete" { } test "Client connection reused after NotStored error" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, .max_idle = 1, }); @@ -303,7 +306,7 @@ test "Client connection reused after NotStored error" { } test "Client connection reused after Exists error (CAS conflict)" { - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = &.{"127.0.0.1:21211"}, .max_idle = 1, }); @@ -337,7 +340,7 @@ test "key distribution across servers" { }; // Create distributed client - var client = try Client.init(std.testing.allocator, .{ + var client = try Client.init(std.testing.allocator, std.testing.io, .{ .servers = servers, .hasher = .rendezvous, }); @@ -358,7 +361,7 @@ test "key distribution across servers" { const host, const port = parseServer(server_str).?; var conn: Connection = undefined; - try conn.connect(std.testing.allocator, host, port, .{}); + try conn.connect(std.testing.allocator, std.testing.io, host, port, .{}); defer conn.close(); var buf: [1024]u8 = undefined; @@ -387,9 +390,8 @@ test "key distribution across servers" { } test "retry after server restart" { - const testing = @import("testing.zig"); - - var client = try Client.init(std.testing.allocator, .{ + const io = std.testing.io; + var client = try Client.init(std.testing.allocator, io, .{ .servers = &.{"127.0.0.1:21211"}, .retry_attempts = 5, .retry_interval = .fromMilliseconds(500), @@ -405,14 +407,14 @@ test "retry after server restart" { try std.testing.expectEqualStrings("before_restart", info1.?.value); // Stop immediately (no grace period) - try testing.runDockerCompose(std.testing.allocator, &.{ "stop", "-t", "0", "memcached-1" }); + try testing.runDockerCompose(std.testing.allocator, io, &.{ "stop", "-t", "0", "memcached-1" }); // Start in background - will take time to be ready var start_thread = try std.Thread.spawn(.{}, struct { - fn run() void { - testing.runDockerCompose(std.testing.allocator, &.{ "start", "memcached-1" }) catch {}; + fn run(_io: std.Io) void { + testing.runDockerCompose(std.testing.allocator, _io, &.{ "start", "memcached-1" }) catch {}; } - }.run, .{}); + }.run, .{io}); // Try immediately with stale connection - should fail and retry try client.set("retry_test_key", "after_restart", .{}); diff --git a/src/Connection.zig b/src/Connection.zig index 0dcca4f..14e7c9c 100644 --- a/src/Connection.zig +++ b/src/Connection.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const zio = @import("zio"); const Allocator = std.mem.Allocator; const Protocol = @import("Protocol.zig"); @@ -7,18 +6,19 @@ const Connection = @This(); node: std.SinglyLinkedList.Node = .{}, gpa: Allocator, -stream: zio.net.Stream, -reader: zio.net.Stream.Reader, -writer: zio.net.Stream.Writer, +io: std.Io, +stream: std.Io.net.Stream, +reader: std.Io.net.Stream.Reader, +writer: std.Io.net.Stream.Writer, read_buffer: []u8, write_buffer: []u8, pub const Options = struct { read_buffer_size: usize = 4096, write_buffer_size: usize = 4096, - connect_timeout: zio.Timeout = .none, - read_timeout: zio.Timeout = .none, - write_timeout: zio.Timeout = .none, + connect_timeout: std.Io.Timeout = .none, + read_timeout: std.Io.Timeout = .none, + write_timeout: std.Io.Timeout = .none, }; // Re-export Protocol types for convenience @@ -27,11 +27,12 @@ pub const GetOpts = Protocol.GetOpts; pub const SetOpts = Protocol.SetOpts; pub const Error = Protocol.Error; -pub fn connect(self: *Connection, gpa: Allocator, host: []const u8, port: u16, options: Options) !void { - const stream = zio.net.tcpConnectToHost(host, port, .{ +pub fn connect(self: *Connection, gpa: Allocator, io: std.Io, host: []const u8, port: u16, options: Options) !void { + const stream = (try std.Io.net.HostName.init(host)).connect(io, port, .{ + .mode = .stream, .timeout = options.connect_timeout, }) catch return error.ConnectionFailed; - errdefer stream.close(); + errdefer stream.close(io); const read_buffer = try gpa.alloc(u8, options.read_buffer_size); errdefer gpa.free(read_buffer); @@ -39,21 +40,38 @@ pub fn connect(self: *Connection, gpa: Allocator, host: []const u8, port: u16, o const write_buffer = try gpa.alloc(u8, options.write_buffer_size); errdefer gpa.free(write_buffer); + var reader = stream.reader(io, read_buffer); + var writer = stream.writer(io, write_buffer); + + if (options.read_timeout != .none) { + if (@hasField(std.Io.net.Stream.Reader, "timeout")) { + reader.timeout = options.read_timeout; + } else { + @panic("read_timeout is set but std.Io.net.Stream.Reader does not support timeouts yet, see https://codeberg.org/ziglang/zig/issues/32166"); + } + } + + if (options.write_timeout != .none) { + if (@hasField(std.Io.net.Stream.Writer, "timeout")) { + writer.timeout = options.write_timeout; + } else { + @panic("write_timeout is set but std.Io.net.Stream.Writer does not support timeouts yet, see https://codeberg.org/ziglang/zig/issues/32166"); + } + } + self.* = .{ .gpa = gpa, + .io = io, .stream = stream, - .reader = stream.reader(read_buffer), - .writer = stream.writer(write_buffer), + .reader = reader, + .writer = writer, .read_buffer = read_buffer, .write_buffer = write_buffer, }; - - self.reader.setTimeout(options.read_timeout); - self.writer.setTimeout(options.write_timeout); } pub fn close(self: *Connection) void { - self.stream.close(); + self.stream.close(self.io); self.gpa.free(self.read_buffer); self.gpa.free(self.write_buffer); } @@ -110,7 +128,7 @@ const testing = @import("testing.zig"); test "simple get/set" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("test_key", "test_value", .{}, .set); @@ -124,7 +142,7 @@ test "simple get/set" { test "get non-existent key returns null" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); var buf: [1024]u8 = undefined; @@ -135,7 +153,7 @@ test "get non-existent key returns null" { test "set with TTL and flags" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("ttl_key", "ttl_value", .{ .ttl = 60, .flags = 42 }, .set); @@ -150,7 +168,7 @@ test "set with TTL and flags" { test "delete" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("delete_key", "to_be_deleted", .{}, .set); @@ -163,7 +181,7 @@ test "delete" { test "incr/decr" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("counter", "10", .{}, .set); @@ -177,7 +195,7 @@ test "incr/decr" { test "version" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); var buf: [64]u8 = undefined; @@ -188,7 +206,7 @@ test "version" { test "add only if not exists" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); // Delete first to ensure clean state @@ -208,7 +226,7 @@ test "add only if not exists" { test "replace only if exists" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); // Delete first to ensure clean state @@ -230,7 +248,7 @@ test "replace only if exists" { test "append/prepend" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("concat_key", "hello", .{}, .set); @@ -249,7 +267,7 @@ test "append/prepend" { test "CAS (compare and swap)" { var conn: Connection = undefined; - try conn.connect(std.testing.allocator, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); + try conn.connect(std.testing.allocator, std.testing.io, "127.0.0.1", @intFromEnum(testing.Node.node1), .{}); defer conn.close(); try conn.set("cas_key", "original", .{}, .set); diff --git a/src/Pool.zig b/src/Pool.zig index 7926e95..3b369a1 100644 --- a/src/Pool.zig +++ b/src/Pool.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const zio = @import("zio"); const Allocator = std.mem.Allocator; const Connection = @import("Connection.zig"); @@ -8,26 +7,28 @@ const log = std.log.scoped(.memcached); const Pool = @This(); gpa: Allocator, +io: std.Io, host: []const u8, port: u16, idle: std.SinglyLinkedList = .{}, idle_count: usize = 0, max_idle: usize, connection_options: Connection.Options, -mutex: zio.Mutex = .init, +mutex: std.Io.Mutex = .init, pub const Options = struct { max_idle: usize = 2, read_buffer_size: usize = 4096, write_buffer_size: usize = 4096, - connect_timeout: zio.Timeout = .none, - read_timeout: zio.Timeout = .none, - write_timeout: zio.Timeout = .none, + connect_timeout: std.Io.Timeout = .none, + read_timeout: std.Io.Timeout = .none, + write_timeout: std.Io.Timeout = .none, }; -pub fn init(gpa: Allocator, host: []const u8, port: u16, options: Options) Pool { +pub fn init(gpa: Allocator, io: std.Io, host: []const u8, port: u16, options: Options) Pool { return .{ .gpa = gpa, + .io = io, .host = host, .port = port, .max_idle = options.max_idle, @@ -50,30 +51,30 @@ pub fn deinit(self: *Pool) void { } pub fn acquire(self: *Pool) !*Connection { - try self.mutex.lock(); + try self.mutex.lock(self.io); // Try to get an idle connection if (self.idle.popFirst()) |node| { self.idle_count -= 1; - self.mutex.unlock(); + self.mutex.unlock(self.io); log.debug("pool {s}:{d} reusing connection (idle: {d})", .{ self.host, self.port, self.idle_count }); return @fieldParentPtr("node", node); } - self.mutex.unlock(); + self.mutex.unlock(self.io); // Create a new connection (outside of lock) log.debug("pool {s}:{d} creating new connection", .{ self.host, self.port }); const conn = try self.gpa.create(Connection); errdefer self.gpa.destroy(conn); - try conn.connect(self.gpa, self.host, self.port, self.connection_options); + try conn.connect(self.gpa, self.io, self.host, self.port, self.connection_options); return conn; } pub fn isEmpty(self: *Pool) bool { - self.mutex.lockUncancelable(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.idle_count == 0; } @@ -86,11 +87,11 @@ pub fn release(self: *Pool, conn: *Connection, ok: bool) void { return; } - self.mutex.lockUncancelable(); + self.mutex.lockUncancelable(self.io); // If pool is full, close the connection if (self.idle_count >= self.max_idle) { - self.mutex.unlock(); + self.mutex.unlock(self.io); log.debug("pool {s}:{d} closing connection (pool full)", .{ self.host, self.port }); conn.close(); self.gpa.destroy(conn); @@ -101,5 +102,5 @@ pub fn release(self: *Pool, conn: *Connection, ok: bool) void { self.idle.prepend(&conn.node); self.idle_count += 1; log.debug("pool {s}:{d} released connection (idle: {d})", .{ self.host, self.port, self.idle_count }); - self.mutex.unlock(); + self.mutex.unlock(self.io); } diff --git a/src/Protocol.zig b/src/Protocol.zig index 83dedca..191b3cb 100644 --- a/src/Protocol.zig +++ b/src/Protocol.zig @@ -231,7 +231,7 @@ pub fn flushAll(self: Protocol) Error!void { try self.writer.flush(); const line = try self.reader.takeDelimiterInclusive('\n'); - const trimmed = std.mem.trimRight(u8, line, "\r\n"); + const trimmed = std.mem.trimEnd(u8, line, "\r\n"); if (!std.mem.eql(u8, trimmed, "OK")) { return error.ServerError; @@ -243,7 +243,7 @@ pub fn version(self: Protocol, buf: []u8) Error![]u8 { try self.writer.flush(); const line = try self.reader.takeDelimiterInclusive('\n'); - const trimmed = std.mem.trimRight(u8, line, "\r\n"); + const trimmed = std.mem.trimEnd(u8, line, "\r\n"); if (std.mem.startsWith(u8, trimmed, "VERSION ")) { const ver = trimmed[8..]; @@ -278,7 +278,7 @@ const ValueInfo = struct { fn readResponse(self: Protocol) Error!Response { const line = try self.reader.takeDelimiterInclusive('\n'); - const trimmed = std.mem.trimRight(u8, line, "\r\n"); + const trimmed = std.mem.trimEnd(u8, line, "\r\n"); return parseResponse(trimmed); } diff --git a/src/Server.zig b/src/Server.zig index 35efbfb..ffb704a 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -9,7 +9,7 @@ port: u16, pool: Pool, hash_id: u64, // precomputed hash for rendezvous -pub fn init(gpa: Allocator, host: []const u8, port: u16, pool_opts: Pool.Options) Server { +pub fn init(gpa: Allocator, io: std.Io, host: []const u8, port: u16, pool_opts: Pool.Options) Server { // Precompute server identity hash var h = std.hash.Wyhash.init(0); h.update(host); @@ -18,7 +18,7 @@ pub fn init(gpa: Allocator, host: []const u8, port: u16, pool_opts: Pool.Options return .{ .host = host, .port = port, - .pool = Pool.init(gpa, host, port, pool_opts), + .pool = Pool.init(gpa, io, host, port, pool_opts), .hash_id = h.final(), }; } @@ -28,19 +28,19 @@ pub fn deinit(self: *Server) void { } test "hash_id is deterministic" { - const s1 = Server.init(std.testing.allocator, "localhost", 11211, .{}); - const s2 = Server.init(std.testing.allocator, "localhost", 11211, .{}); + const s1 = Server.init(std.testing.allocator, std.testing.io, "localhost", 11211, .{}); + const s2 = Server.init(std.testing.allocator, std.testing.io, "localhost", 11211, .{}); try std.testing.expectEqual(s1.hash_id, s2.hash_id); } test "hash_id differs for different servers" { - const s1 = Server.init(std.testing.allocator, "server1", 11211, .{}); - const s2 = Server.init(std.testing.allocator, "server2", 11211, .{}); + const s1 = Server.init(std.testing.allocator, std.testing.io, "server1", 11211, .{}); + const s2 = Server.init(std.testing.allocator, std.testing.io, "server2", 11211, .{}); try std.testing.expect(s1.hash_id != s2.hash_id); } test "hash_id differs for different ports" { - const s1 = Server.init(std.testing.allocator, "localhost", 11211, .{}); - const s2 = Server.init(std.testing.allocator, "localhost", 11212, .{}); + const s1 = Server.init(std.testing.allocator, std.testing.io, "localhost", 11211, .{}); + const s2 = Server.init(std.testing.allocator, std.testing.io, "localhost", 11212, .{}); try std.testing.expect(s1.hash_id != s2.hash_id); } diff --git a/src/root.zig b/src/root.zig index 7dd5e76..1c1045a 100644 --- a/src/root.zig +++ b/src/root.zig @@ -9,8 +9,8 @@ pub const Server = @import("Server.zig"); pub const Hasher = @import("hasher.zig").Hasher; /// Connect to a single memcached server with default options. -pub fn connect(gpa: Allocator, server: []const u8) !Client { - return Client.init(gpa, .{ .servers = &.{server} }); +pub fn connect(gpa: Allocator, io: std.Io, server: []const u8) !Client { + return Client.init(gpa, io, .{ .servers = &.{server} }); } test { diff --git a/src/testing.zig b/src/testing.zig index dbc502a..3a465fc 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -1,8 +1,4 @@ const std = @import("std"); -const zio = @import("zio"); - -const gpa = std.heap.smp_allocator; -pub var runtime: *zio.Runtime = undefined; pub const Node = enum(u16) { node1 = 21211, @@ -10,60 +6,65 @@ pub const Node = enum(u16) { node3 = 21213, }; -pub fn runDockerComposeCapture(allocator: std.mem.Allocator, compose_args: []const []const u8) !std.process.Child.RunResult { - var args: std.ArrayListUnmanaged([]const u8) = .{}; +pub fn runDockerComposeCapture(allocator: std.mem.Allocator, io: std.Io, compose_args: []const []const u8) !std.process.RunResult { + var args: std.ArrayListUnmanaged([]const u8) = .empty; defer args.deinit(allocator); try args.appendSlice(allocator, &.{ "docker", "compose", "-f", "docker-compose.test.yml", "-p", "memcached-zig-test" }); try args.appendSlice(allocator, compose_args); - return try std.process.Child.run(.{ - .allocator = allocator, + return try std.process.run(allocator, io, .{ .argv = args.items, }); } -pub fn runDockerCompose(allocator: std.mem.Allocator, compose_args: []const []const u8) !void { - const result = try runDockerComposeCapture(allocator, compose_args); +pub fn runDockerCompose(allocator: std.mem.Allocator, io: std.Io, compose_args: []const []const u8) !void { + const result = try runDockerComposeCapture(allocator, io, compose_args); defer allocator.free(result.stderr); defer allocator.free(result.stdout); } -pub fn waitForServices(timeout_ms: i64) !void { +pub fn waitForServices(io: std.Io, timeout: std.Io.Duration) !void { const nodes = [_]Node{ .node1, .node2, .node3 }; for (nodes) |node| { - try waitForNode(node, timeout_ms); + try waitForNode(io, node, timeout); } } -pub fn waitForNode(node: Node, timeout_ms: i64) !void { - const deadline = std.time.milliTimestamp() + timeout_ms; - while (std.time.milliTimestamp() < deadline) { - if (tryConnect(node)) return; - std.Thread.sleep(100 * std.time.ns_per_ms); +pub fn waitForNode(io: std.Io, node: Node, timeout: std.Io.Duration) !void { + const deadline = std.Io.Clock.Timestamp.now(io, .awake).addDuration(.{ .raw = timeout, .clock = .awake }); + while (true) { + if (try tryConnect(io, node)) { + try io.sleep(.fromMilliseconds(500), .awake); + return; + } + if (!std.Io.Clock.Timestamp.now(io, .awake).compare(.lt, deadline)) { + return error.ServiceNotHealthy; + } + try io.sleep(.fromMilliseconds(100), .awake); } - return error.ServiceNotHealthy; } -fn tryConnect(node: Node) bool { - const stream = zio.net.tcpConnectToHost("127.0.0.1", @intFromEnum(node), .{}) catch return false; - stream.close(); +fn tryConnect(io: std.Io, node: Node) std.Io.Cancelable!bool { + const addr = std.Io.net.IpAddress.parse("127.0.0.1", @intFromEnum(node)) catch return false; + const stream = addr.connect(io, .{ .mode = .stream }) catch |err| { + if (err == error.Canceled) return error.Canceled; + return false; + }; + stream.close(io); return true; } // --- Test lifecycle hooks --- test "tests:beforeAll" { - runtime = try zio.Runtime.init(gpa, .{}); - errdefer runtime.deinit(); - - try runDockerCompose(gpa, &.{ "up", "-d" }); - errdefer runDockerCompose(gpa, &.{"down"}) catch {}; + const io = std.testing.io; + try runDockerCompose(std.testing.allocator, io, &.{ "up", "-d" }); + errdefer runDockerCompose(std.testing.allocator, io, &.{"down"}) catch {}; - try waitForServices(30_000); + try waitForServices(io, .fromSeconds(30)); } test "tests:afterAll" { - try runDockerCompose(gpa, &.{"down"}); - runtime.deinit(); + try runDockerCompose(std.testing.allocator, std.testing.io, &.{"down"}); } diff --git a/test_runner.zig b/test_runner.zig index 39ef603..ba91817 100644 --- a/test_runner.zig +++ b/test_runner.zig @@ -5,7 +5,6 @@ // }); pub const std_options = std.Options{ - .log_level = .debug, .log_scope_levels = &[_]std.log.ScopeLevel{ .{ .scope = .websocket, .level = .warn }, }, @@ -15,14 +14,18 @@ pub const std_options = std.Options{ const std = @import("std"); const builtin = @import("builtin"); +const Io = std.Io; const Allocator = std.mem.Allocator; const BORDER = "=" ** 80; -// Log capture for suppressing logs in passing tests +// Log capture for suppressing logs in passing tests. +// The Io is stashed at startup so the global log callback can perform mutex +// operations without having to thread `Io` through every call site. const LogCapture = struct { capture_writer: ?*std.Io.Writer = null, - mutex: std.Thread.Mutex = .{}, + mutex: Io.Mutex = .init, + io: ?Io = null, pub fn logFn( self: *@This(), @@ -31,8 +34,14 @@ const LogCapture = struct { comptime format: []const u8, args: anytype, ) void { - self.mutex.lock(); - defer self.mutex.unlock(); + const io = self.io orelse { + // No Io available yet — fall back to raw stderr. + const scope_prefix = "(" ++ @tagName(scope) ++ "/" ++ @tagName(level) ++ "): "; + std.debug.print(scope_prefix ++ format ++ "\n", args); + return; + }; + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); const scope_prefix = "(" ++ @tagName(scope) ++ "/" ++ @tagName(level) ++ "): "; @@ -40,20 +49,20 @@ const LogCapture = struct { // Write to capture buffer writer.print(scope_prefix ++ format ++ "\n", args) catch return; } else { - // Write to stderr (no locking needed, std.debug.print handles it) + // Write to stderr (std.debug.print handles its own locking) std.debug.print(scope_prefix ++ format ++ "\n", args); } } - pub fn startCapture(self: *@This(), writer: *std.Io.Writer) void { - self.mutex.lock(); - defer self.mutex.unlock(); + pub fn startCapture(self: *@This(), io: Io, writer: *std.Io.Writer) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); self.capture_writer = writer; } - pub fn stopCapture(self: *@This()) void { - self.mutex.lock(); - defer self.mutex.unlock(); + pub fn stopCapture(self: *@This(), io: Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); self.capture_writer = null; } }; @@ -72,33 +81,40 @@ pub fn customLogFn( // use in custom panic handler var current_test: ?[]const u8 = null; -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); +pub fn main(init: std.process.Init) !void { + const io = init.io; + const gpa = init.gpa; - const allocator = gpa.allocator(); + log_capture.io = io; + defer log_capture.io = null; - var env = Env.init(allocator); - defer env.deinit(allocator); + var env = Env.init(gpa, init.environ_map); + defer env.deinit(gpa); - var slowest = SlowTracker.init(allocator, 5); - defer slowest.deinit(); + var slowest = SlowTracker.init(io, 5); + defer slowest.deinit(gpa); var pass: usize = 0; var fail: usize = 0; var skip: usize = 0; var leak: usize = 0; - var log_buffer: std.Io.Writer.Allocating = .init(allocator); + var log_buffer: std.Io.Writer.Allocating = .init(gpa); defer log_buffer.deinit(); var failed_tests: std.ArrayList([]const u8) = .empty; - defer failed_tests.deinit(allocator); + defer failed_tests.deinit(gpa); Printer.fmt("\r\x1b[0K", .{}); // beginning of line and clear to end of line for (builtin.test_functions) |t| { if (isSetup(t)) { + std.testing.io_instance = .init(gpa, .{ + .argv0 = .init(init.minimal.args), + .environ = init.minimal.environ, + }); + std.testing.allocator_instance = .{ .backing_allocator = gpa }; + t.func() catch |err| { Printer.status(.fail, "\nsetup \"{s}\" failed: {}\n", .{ t.name, err }); return err; @@ -128,7 +144,7 @@ pub fn main() !void { break :blk count; }; - const root_node = if (!env.verbose) std.Progress.start(.{ + const root_node = if (env.verbose == .off) std.Progress.start(io, .{ .root_name = "Running tests", .estimated_total_items = test_count, }) else std.Progress.Node.none; @@ -141,7 +157,7 @@ pub fn main() !void { } var status = Status.pass; - slowest.startTiming(); + slowest.startTiming(io); const is_unnamed_test = isUnnamed(t); if (env.filters.items.len > 0) { @@ -177,32 +193,40 @@ pub fn main() !void { current_test = friendly_name; std.testing.allocator_instance = .{}; + std.testing.io_instance = .init(gpa, .{ + .argv0 = .init(init.minimal.args), + .environ = init.minimal.environ, + }); if (env.do_log_capture) { log_buffer.clearRetainingCapacity(); - log_capture.startCapture(&log_buffer.writer); + log_capture.startCapture(io, &log_buffer.writer); } // Print test name before running (for debugging hangs) - if (env.verbose) { - Printer.fmt("{s} .. ", .{friendly_name}); + switch (env.verbose) { + .naming => Printer.fmt("{s} .. ", .{friendly_name}), + .tracing => Printer.fmt("{s}\n", .{friendly_name}), + .off => {}, } const result = t.func(); if (env.do_log_capture) { - log_capture.stopCapture(); + log_capture.stopCapture(io); } current_test = null; - const ns_taken = slowest.endTiming(friendly_name); + const ns_taken = slowest.endTiming(io, gpa, friendly_name); if (std.testing.allocator_instance.deinit() == .leak) { leak += 1; Printer.status(.fail, "\n{s}\n\"{s}\" - Memory Leak\n{s}\n", .{ BORDER, friendly_name, BORDER }); } + std.testing.io_instance.deinit(); + var fail_err: ?anyerror = null; if (result) |_| { pass += 1; @@ -215,7 +239,7 @@ pub fn main() !void { status = .fail; fail += 1; fail_err = err; - failed_tests.append(allocator, friendly_name) catch {}; + failed_tests.append(gpa, friendly_name) catch {}; }, } @@ -226,9 +250,17 @@ pub fn main() !void { .skip => "SKIP", .text => "", }; - if (env.verbose) { - Printer.status(status, "{s}", .{status_str}); - Printer.fmt(" ({d:.2}ms)\n", .{ms}); + switch (env.verbose) { + .naming => { + Printer.status(status, "{s}", .{status_str}); + Printer.fmt(" ({d:.2}ms)\n", .{ms}); + }, + .tracing => { + Printer.fmt(" ", .{}); + Printer.status(status, "{s}", .{status_str}); + Printer.fmt(" ({d:.2}ms)\n", .{ms}); + }, + .off => {}, } // Print error details for failures (in non-verbose mode, progress will show above this) @@ -243,11 +275,7 @@ pub fn main() !void { Printer.fmt("{s}\n", .{BORDER}); if (@errorReturnTrace()) |trace| { - if (builtin.zig_version.major == 0 and builtin.zig_version.minor < 16) { - std.debug.dumpStackTrace(trace.*); - } else { - std.debug.dumpStackTrace(trace); - } + std.debug.dumpErrorReturnTrace(trace); } if (env.fail_first) { break; @@ -257,6 +285,12 @@ pub fn main() !void { for (builtin.test_functions) |t| { if (isTeardown(t)) { + std.testing.io_instance = .init(gpa, .{ + .argv0 = .init(init.minimal.args), + .environ = init.minimal.environ, + }); + std.testing.allocator_instance = .{ .backing_allocator = gpa }; + t.func() catch |err| { Printer.status(.fail, "\nteardown \"{s}\" failed: {}\n", .{ t.name, err }); return err; @@ -288,7 +322,7 @@ pub fn main() !void { Printer.fmt("\n", .{}); try slowest.display(); Printer.fmt("\n", .{}); - std.posix.exit(if (fail == 0) 0 else 1); + std.process.exit(if (fail == 0) 0 else 1); } const Printer = struct { @@ -318,16 +352,13 @@ const SlowTracker = struct { const SlowestQueue = std.PriorityDequeue(TestInfo, void, compareTiming); max: usize, slowest: SlowestQueue, - timer: std.time.Timer, + started: Io.Clock.Timestamp, - fn init(allocator: Allocator, count: u32) SlowTracker { - const timer = std.time.Timer.start() catch @panic("failed to start timer"); - var slowest = SlowestQueue.init(allocator, {}); - slowest.ensureTotalCapacity(count) catch @panic("OOM"); + fn init(io: Io, count: u32) SlowTracker { return .{ .max = count, - .timer = timer, - .slowest = slowest, + .started = .now(io, .awake), + .slowest = SlowestQueue.initContext({}), }; } @@ -336,24 +367,24 @@ const SlowTracker = struct { name: []const u8, }; - fn deinit(self: SlowTracker) void { - self.slowest.deinit(); + fn deinit(self: *SlowTracker, allocator: Allocator) void { + self.slowest.deinit(allocator); } - fn startTiming(self: *SlowTracker) void { - self.timer.reset(); + fn startTiming(self: *SlowTracker, io: Io) void { + self.started = .now(io, .awake); } - fn endTiming(self: *SlowTracker, test_name: []const u8) u64 { - var timer = self.timer; - const ns = timer.lap(); + fn endTiming(self: *SlowTracker, io: Io, allocator: Allocator, test_name: []const u8) u64 { + const now = Io.Clock.Timestamp.now(io, .awake); + const ns: u64 = @intCast(self.started.durationTo(now).raw.nanoseconds); var slowest = &self.slowest; if (slowest.count() < self.max) { // Capacity is fixed to the # of slow tests we want to track // If we've tracked fewer tests than this capacity, than always add - slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + slowest.push(allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } @@ -368,8 +399,8 @@ const SlowTracker = struct { } // the previous fastest of our slow tests, has been pushed off. - _ = slowest.removeMin(); - slowest.add(TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); + _ = slowest.popMin(); + slowest.push(allocator, TestInfo{ .ns = ns, .name = test_name }) catch @panic("failed to track test timing"); return ns; } @@ -377,7 +408,7 @@ const SlowTracker = struct { var slowest = self.slowest; const count = slowest.count(); Printer.fmt("Slowest {d} test{s}: \n", .{ count, if (count != 1) "s" else "" }); - while (slowest.removeMinOrNull()) |info| { + while (slowest.popMin()) |info| { const ms = @as(f64, @floatFromInt(info.ns)) / 1_000_000.0; Printer.fmt(" {d:.2}ms\t{s}\n", .{ ms, info.name }); } @@ -390,17 +421,17 @@ const SlowTracker = struct { }; const Env = struct { - verbose: bool, + const Verbose = enum { off, naming, tracing }; + + verbose: Verbose, fail_first: bool, filters: std.ArrayList([]const u8), do_log_capture: bool, - fn init(allocator: Allocator) Env { + fn init(allocator: Allocator, environ_map: *std.process.Environ.Map) Env { var filters: std.ArrayList([]const u8) = .empty; - if (readEnv(allocator, "TEST_FILTER")) |filter_str| { - defer allocator.free(filter_str); - + if (environ_map.get("TEST_FILTER")) |filter_str| { var iter = std.mem.splitScalar(u8, filter_str, '|'); while (iter.next()) |part| { const trimmed = std.mem.trim(u8, part, " \t"); @@ -412,10 +443,10 @@ const Env = struct { } return .{ - .verbose = readEnvBool(allocator, "TEST_VERBOSE", false), - .fail_first = readEnvBool(allocator, "TEST_FAIL_FIRST", false), + .verbose = readEnvVerbose(environ_map), + .fail_first = readEnvBool(environ_map, "TEST_FAIL_FIRST", false), .filters = filters, - .do_log_capture = readEnvBool(allocator, "TEST_LOG_CAPTURE", true), + .do_log_capture = readEnvBool(environ_map, "TEST_LOG_CAPTURE", true), }; } @@ -426,21 +457,16 @@ const Env = struct { self.filters.deinit(allocator); } - fn readEnv(allocator: Allocator, key: []const u8) ?[]const u8 { - const v = std.process.getEnvVarOwned(allocator, key) catch |err| { - if (err == error.EnvironmentVariableNotFound) { - return null; - } - std.log.warn("failed to get env var {s} due to err {}", .{ key, err }); - return null; - }; - return v; + fn readEnvBool(environ_map: *std.process.Environ.Map, key: []const u8, deflt: bool) bool { + const value = environ_map.get(key) orelse return deflt; + return std.ascii.eqlIgnoreCase(value, "true"); } - fn readEnvBool(allocator: Allocator, key: []const u8, deflt: bool) bool { - const value = readEnv(allocator, key) orelse return deflt; - defer allocator.free(value); - return std.ascii.eqlIgnoreCase(value, "true"); + fn readEnvVerbose(environ_map: *std.process.Environ.Map) Verbose { + const value = environ_map.get("TEST_VERBOSE") orelse return .off; + if (std.ascii.eqlIgnoreCase(value, "2")) return .tracing; + if (std.ascii.eqlIgnoreCase(value, "true") or std.ascii.eqlIgnoreCase(value, "1")) return .naming; + return .off; } };