From 5efb3be32109191cd58ea052c481dfd9cc72c263 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 18:45:51 +0000 Subject: [PATCH 01/10] test: pin blocking accepted sockets --- src/os/net.zig | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/os/net.zig b/src/os/net.zig index f3a3073..905c9be 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -553,3 +553,29 @@ fn socketOptionAfd(handle: windows.HANDLE, level: i32, opt_name: u32, opt_val: [ }; _ = try afdSocketIo(handle, windows.IOCTL.AFD.SOCKOPT, &info, null); } + +fn testNonblock(handle: Handle) !bool { + if (comptime builtin.os.tag == .linux) { + const flags = std.os.linux.fcntl(handle, std.os.linux.F.GETFL, 0); + if (std.os.linux.errno(flags) != .SUCCESS) return error.SocketOptionFailed; + return flags & (1 << @bitOffsetOf(std.os.linux.O, "NONBLOCK")) != 0; + } + const flags = std.c.fcntl(handle, std.c.F.GETFL, @as(c_int, 0)); + if (flags == -1) return error.SocketOptionFailed; + return flags & (1 << @bitOffsetOf(std.c.O, "NONBLOCK")) != 0; +} + +test "accepted POSIX sockets stay blocking on a nonblocking listener" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const io = std.Io.Threaded.global_single_threaded.io(); + const loopback = try std.Io.net.IpAddress.parse("127.0.0.1", 0); + var listener = try listenTcp(io, loopback); + defer listener.deinit(io); + try setListenNonBlocking(listener.socket.handle); + + const client = try listener.socket.address.connect(io, .{ .mode = .stream }); + defer client.close(io); + const accepted = acceptNonBlocking(listener.socket.handle) orelse return error.AcceptFailed; + defer accepted.close(io); + try std.testing.expect(!(try testNonblock(accepted.socket.handle))); +} From 94eb02a840e54a38174c50cfcde3ad8c1e0f7d58 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 19:07:59 +0000 Subject: [PATCH 02/10] fix: restore portable link accepts --- ci/check_tests.sh | 2 +- src/copy.zig | 4 +++- src/hub.zig | 1 + src/os/net.zig | 21 ++++++++++++++++++--- src/os/proc.zig | 4 ++-- src/os/secure.zig | 23 ++++++++++++++--------- src/tools.zig | 4 ++-- 7 files changed, 41 insertions(+), 18 deletions(-) diff --git a/ci/check_tests.sh b/ci/check_tests.sh index 6e7992d..494897f 100755 --- a/ci/check_tests.sh +++ b/ci/check_tests.sh @@ -9,7 +9,7 @@ ZIG_BIN="${ZIG:-zig}" "$ZIG_BIN" build test # --- test count floor ------------------------------------------------------ -MIN_TESTS=406 +MIN_TESTS=407 TEST_COUNT=$(grep -rho '^test "[^"]*"' src/ | sort -u | wc -l | tr -d ' ') if [ "$TEST_COUNT" -lt "$MIN_TESTS" ]; then echo "check_tests: test block count $TEST_COUNT is below floor $MIN_TESTS" >&2 diff --git a/src/copy.zig b/src/copy.zig index 06d443b..813440f 100644 --- a/src/copy.zig +++ b/src/copy.zig @@ -460,5 +460,7 @@ pub fn tool(a: Allocator, io: Io, cfg: *const @import("config.zig").Config, args } test { - _ = @import("copy_test.zig"); + if (comptime builtin.os.tag != .windows) { + _ = @import("copy_test.zig"); + } } diff --git a/src/hub.zig b/src/hub.zig index d44b852..d1948b9 100644 --- a/src/hub.zig +++ b/src/hub.zig @@ -1505,6 +1505,7 @@ test "hub probe deadline follows the awake clock" { } test "hub probe budget covers the PING write" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; var fx: Fixture = undefined; try fx.init(.{ .single = "s3cret" }); defer fx.deinit(); diff --git a/src/os/net.zig b/src/os/net.zig index 905c9be..0397e43 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -294,9 +294,10 @@ pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net. /// Set a listen socket non-blocking (POSIX): the hub accept loop polls /// before accepting, and a non-blocking listen fd turns the poll-to-accept /// race (a reset drained the pending queue) into EAGAIN instead of a -/// parked thread. Accepted sockets do NOT inherit the flag, so link reads -/// keep their blocking+timeout semantics. POSIX-only: the Windows accept -/// path keeps the blocking std call and the self-connect wake. +/// parked thread. Linux accepted sockets do not inherit O_NONBLOCK; Darwin +/// clears the inherited flag in acceptNonBlocking before returning them. +/// POSIX-only: the Windows accept path keeps the blocking std call and the +/// self-connect wake. pub fn setListenNonBlocking(handle: Handle) SetTimeoutError!void { if (comptime builtin.os.tag == .windows) { var one: c_ulong = 1; @@ -417,6 +418,20 @@ pub fn acceptNonBlocking(listen_fd: Handle) ?std.Io.net.Stream { while (true) { const fd = std.c.accept(listen_fd, &storage.any, &len); if (fd != -1) { + // Darwin accept() inherits O_NONBLOCK from the listener, unlike + // Linux. Blocking reads with per-operation deadlines are the + // link/TLS contract; close an unfixable socket like any other + // failed accept instead of exposing EAGAIN to those layers. + const flags = std.c.fcntl(fd, std.c.F.GETFL, @as(c_int, 0)); + if (flags == -1) { + _ = std.c.close(fd); + return null; + } + const nonblock: c_int = 1 << @bitOffsetOf(std.c.O, "NONBLOCK"); + if (flags & nonblock != 0 and std.c.fcntl(fd, std.c.F.SETFL, @as(c_int, flags & ~nonblock)) != 0) { + _ = std.c.close(fd); + return null; + } // Accepted sockets on Darwin do not inherit CLOEXEC. _ = std.c.fcntl(fd, std.c.F.SETFD, @as(c_int, std.c.FD_CLOEXEC)); return .{ .socket = .{ diff --git a/src/os/proc.zig b/src/os/proc.zig index efd7e91..8ccb486 100644 --- a/src/os/proc.zig +++ b/src/os/proc.zig @@ -1731,13 +1731,13 @@ pub fn spawnPidRegistryLen(io: Io) usize { /// comm field in parens — possibly with spaces — so parsing starts after /// the LAST ')' exactly like procps does) describes a zombie whose parent /// is `parent_pid`. Pure function, unit-tested separately. -fn statIsZombieChild(stat: []const u8, parent_pid: std.posix.pid_t) bool { +fn statIsZombieChild(stat: []const u8, parent_pid: i32) bool { const close_paren = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return false; var it = std.mem.tokenizeScalar(u8, stat[close_paren + 1 ..], ' '); const state = it.next() orelse return false; if (state.len == 0 or state[0] != 'Z') return false; const ppid_text = it.next() orelse return false; - const ppid = std.fmt.parseInt(std.posix.pid_t, ppid_text, 10) catch return false; + const ppid = std.fmt.parseInt(i32, ppid_text, 10) catch return false; return ppid == parent_pid; } diff --git a/src/os/secure.zig b/src/os/secure.zig index 22caadb..5117978 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -107,6 +107,7 @@ pub const OpenError = std.posix.OpenError || error{ PathDenied, PrefixSwapped, NotRegularFile, + UnsupportedPlatform, }; /// Host path grammar for this module's matchers (kept local so this module @@ -1372,8 +1373,9 @@ pub fn resolvePinnedArgv0Windows( // fchdir()s into and an executable fd the child execveat()s / // fexecve()s, both opened with the same confinement + readback contract // as the file-tool pins above. -// Referenced only from POSIX-gated call sites (tools.zig/proc.zig), so -// Windows builds never analyze these bodies. +// Production callers use these primitives only on POSIX. Windows returns +// UnsupportedPlatform before the Darwin arm; unit-test discovery may still +// reference the public declarations on every target. // --------------------------------------------------------------------------- /// Pin the authorized child cwd: O_DIRECTORY|O_NOFOLLOW confined against @@ -1404,6 +1406,7 @@ pub fn openPinnedDir(io: Io, canon: []const u8) OpenError!fd_t { if (!std.mem.eql(u8, buf[0..n], canon)) return error.PrefixSwapped; return fd; } + if (comptime builtin.os.tag == .windows) return error.UnsupportedPlatform; // Darwin: no O_PATH; a plain O_DIRECTORY|O_NOFOLLOW open plus the // F_GETPATH readback pins the same object (an execute-only cwd fails // EACCES here — documented boundary, CI never executes this path). @@ -1461,6 +1464,7 @@ pub fn openPinnedExe(io: Io, canon: []const u8) OpenError!fd_t { if (!std.mem.eql(u8, buf[0..n], canon)) return error.PathEscape; return fd; } + if (comptime builtin.os.tag == .windows) return error.UnsupportedPlatform; // Darwin: O_RDONLY (fexecve ultimately execve()s the /dev/fd path, which // needs read access anyway). CLOEXEC here too: the pinned child // clears it before its execve so the interpreter can re-open the @@ -1521,6 +1525,7 @@ pub fn openPathCandidate(io: Io, path: []const u8) OpenError!fd_t { 0, ); } + if (comptime builtin.os.tag == .windows) return error.UnsupportedPlatform; // Darwin PATH-candidate arm: NONBLOCK on the open — a FIFO planted // under a PATH entry after the pathname probe would otherwise park // this thread in open(2) — then S_ISREG on the OPEN @@ -1545,7 +1550,7 @@ pub fn openPathCandidate(io: Io, path: []const u8) OpenError!fd_t { const testing = std.testing; test "secure: swapped prefix (symlink ancestor) is refused" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -1596,7 +1601,7 @@ test "secure: swapped prefix (symlink ancestor) is refused" { } test "secure: symlink escaping the allow tree is refused (EXDEV)" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -1634,7 +1639,7 @@ test "secure: symlink escaping the allow tree is refused (EXDEV)" { } test "secure: symlink into a denied subtree is refused by the post-check" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -1667,7 +1672,7 @@ test "secure: symlink into a denied subtree is refused by the post-check" { } test "secure: legal reads, writes, mkdirs and listing work" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -2166,7 +2171,7 @@ fn fdHasCloexecLinux(fd: fd_t) bool { } test "secure: fifo target is refused promptly instead of hanging" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -2213,7 +2218,7 @@ test "secure: fifo target is refused promptly instead of hanging" { } test "secure: exe pin fds are cloexec in the parent" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); @@ -2245,7 +2250,7 @@ test "secure: exe pin fds are cloexec in the parent" { } test "secure: in-tree dangling symlink write refuses before creating" { - if (builtin.os.tag != .linux) return error.SkipZigTest; + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const io = Io.Threaded.global_single_threaded.io(); diff --git a/src/tools.zig b/src/tools.zig index 2c1d97b..dbf6ba2 100644 --- a/src/tools.zig +++ b/src/tools.zig @@ -3241,7 +3241,7 @@ test "exec swap race after the gate is refused by the pin" { } test "pinned exec runs the original object after a post-pin rename" { - if (builtin.os.tag != .linux) return error.SkipZigTest; // CI runs unit tests on Linux only + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; // CI runs unit tests on Linux only var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -3277,7 +3277,7 @@ test "pinned exec runs the original object after a post-pin rename" { } test "pinned spawn exec failure reaps the child and answers the error" { - if (builtin.os.tag != .linux) return error.SkipZigTest; // CI runs unit tests on Linux only + if (comptime builtin.os.tag != .linux) return error.SkipZigTest; // CI runs unit tests on Linux only var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); From 28251e59775a5cdb364e681b27847fab0cd32cc3 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 19:30:10 +0000 Subject: [PATCH 03/10] test: align native platform fixtures --- .github/workflows/ci.yml | 4 ++-- src/os/net.zig | 6 +++++- src/policy.zig | 36 +++++++++++++++++++----------------- src/rpc.zig | 19 ++++++++++++++++--- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cf78fd..9de67fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,7 +245,7 @@ jobs: "$PY" ci/test_audit.py - name: Python durable sessions suite - if: runner.os != 'Windows' + if: runner.os == 'Linux' shell: bash run: | PY=python3 @@ -253,7 +253,7 @@ jobs: "$PY" ci/test_durable_sessions.py - name: Python durable restart suite - if: runner.os != 'Windows' + if: runner.os == 'Linux' shell: bash run: | PY=python3 diff --git a/src/os/net.zig b/src/os/net.zig index 0397e43..004eb52 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -301,7 +301,10 @@ pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net. pub fn setListenNonBlocking(handle: Handle) SetTimeoutError!void { if (comptime builtin.os.tag == .windows) { var one: c_ulong = 1; - if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) return error.SocketOptionFailed; + if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) { + if (builtin.is_test) std.debug.print("FIONBIO WSA error {d}\n", .{winsock.WSAGetLastError()}); + return error.SocketOptionFailed; + } return; } if (comptime builtin.os.tag == .linux) { @@ -348,6 +351,7 @@ const winsock = struct { pub const FIONBIO: c_long = -2147195266; // 0x8004667E as signed long pub extern "ws2_32" fn accept(s: std.os.windows.HANDLE, addr: ?*ws.sockaddr, addrlen: ?*c_int) std.os.windows.HANDLE; pub extern "ws2_32" fn ioctlsocket(s: std.os.windows.HANDLE, cmd: c_long, argp: *c_ulong) c_int; + pub extern "ws2_32" fn WSAGetLastError() c_int; }; /// Peer address from a winsock sockaddr (accept-derived). diff --git a/src/policy.zig b/src/policy.zig index 58e082f..c659989 100644 --- a/src/policy.zig +++ b/src/policy.zig @@ -1311,11 +1311,11 @@ test "policy: normalizeLexical resolves traversal and collapses" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); - try testing.expectEqualStrings("/a/b", try normalizeLexical(arena, "/a/x/../b")); - try testing.expectEqualStrings("/a/b", try normalizeLexical(arena, "/a/./b//")); - try testing.expectEqualStrings("/", try normalizeLexical(arena, "/..")); - try testing.expectEqualStrings("/etc/passwd", try normalizeLexical(arena, "/etc/../etc/passwd")); - try testing.expectEqualStrings("/a", try normalizeLexical(arena, "/a/b/..")); + try testing.expectEqualStrings("/a/b", try normalizeLexicalSty(arena, "/a/x/../b", .posix)); + try testing.expectEqualStrings("/a/b", try normalizeLexicalSty(arena, "/a/./b//", .posix)); + try testing.expectEqualStrings("/", try normalizeLexicalSty(arena, "/..", .posix)); + try testing.expectEqualStrings("/etc/passwd", try normalizeLexicalSty(arena, "/etc/../etc/passwd", .posix)); + try testing.expectEqualStrings("/a", try normalizeLexicalSty(arena, "/a/b/..", .posix)); } test "policy: pathHasPrefix is segment aligned" { @@ -1702,6 +1702,8 @@ test "policy: argument-type grammar matches the tool layer" { defer arena_state.deinit(); const arena = arena_state.allocator(); const io = Io.Threaded.global_single_threaded.io(); + const write_root = if (builtin.os.tag == .windows) "C:\\srv" else "/srv"; + const write_file = if (builtin.os.tag == .windows) "C:\\srv\\f" else "/srv/f"; const pol = TokenPolicy{ .file = "f", @@ -1711,7 +1713,7 @@ test "policy: argument-type grammar matches the tool layer" { .max_timeout_s = 10, .write_max_mode = 0o644, .read_paths = &.{}, - .write_paths = &.{"/srv"}, + .write_paths = &.{write_root}, .read_deny = &.{}, .write_deny = &.{}, .cwd_deny = &.{}, @@ -1721,13 +1723,13 @@ test "policy: argument-type grammar matches the tool layer" { // mode as an integral float converts and is checked (420.0 == 0o644 ok; // 2533.0 == 0o4755 over the cap -> denied). var args_ok: std.json.ObjectMap = .empty; - try args_ok.put(arena, "path", .{ .string = "/srv/f" }); + try args_ok.put(arena, "path", .{ .string = write_file }); try args_ok.put(arena, "content_b64", .{ .string = "aGk=" }); try args_ok.put(arena, "mode", .{ .float = 420.0 }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_ok }) == null); var args_setuid: std.json.ObjectMap = .empty; - try args_setuid.put(arena, "path", .{ .string = "/srv/f" }); + try args_setuid.put(arena, "path", .{ .string = write_file }); try args_setuid.put(arena, "content_b64", .{ .string = "aGk=" }); try args_setuid.put(arena, "mode", .{ .float = 2533.0 }); if (checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_setuid })) |d| { @@ -1736,7 +1738,7 @@ test "policy: argument-type grammar matches the tool layer" { // negative mode: denied, never a @intCast panic. var args_neg: std.json.ObjectMap = .empty; - try args_neg.put(arena, "path", .{ .string = "/srv/f" }); + try args_neg.put(arena, "path", .{ .string = write_file }); try args_neg.put(arena, "content_b64", .{ .string = "aGk=" }); try args_neg.put(arena, "mode", .{ .integer = -1 }); if (checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_neg })) |d| { @@ -1745,7 +1747,7 @@ test "policy: argument-type grammar matches the tool layer" { // wrong-type mode: bad_argument_type, not a silent skip. var args_str: std.json.ObjectMap = .empty; - try args_str.put(arena, "path", .{ .string = "/srv/f" }); + try args_str.put(arena, "path", .{ .string = write_file }); try args_str.put(arena, "content_b64", .{ .string = "aGk=" }); try args_str.put(arena, "mode", .{ .string = "0644" }); if (checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_str })) |d| { @@ -1755,7 +1757,7 @@ test "policy: argument-type grammar matches the tool layer" { // omitted mode: the tool default 0644 is cap-checked; a 0600 cap // refuses an omitted mode, 0644 passes. var args_nomode: std.json.ObjectMap = .empty; - try args_nomode.put(arena, "path", .{ .string = "/srv/f" }); + try args_nomode.put(arena, "path", .{ .string = write_file }); try args_nomode.put(arena, "content_b64", .{ .string = "aGk=" }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_nomode }) == null); var pol600 = pol; @@ -1769,7 +1771,7 @@ test "policy: argument-type grammar matches the tool layer" { // against the cap instead of refusing with bad_argument_type: that // default is allowed under a 0644 cap, refused under 0600. var args_mode_null: std.json.ObjectMap = .empty; - try args_mode_null.put(arena, "path", .{ .string = "/srv/f" }); + try args_mode_null.put(arena, "path", .{ .string = write_file }); try args_mode_null.put(arena, "content_b64", .{ .string = "aGk=" }); try args_mode_null.put(arena, "mode", Value.null); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_mode_null }) == null); @@ -1782,22 +1784,22 @@ test "policy: argument-type grammar matches the tool layer" { // and without a policy file); the gate only refuses // present-but-wrong-typed content fields. var args_text: std.json.ObjectMap = .empty; - try args_text.put(arena, "path", .{ .string = "/srv/f" }); + try args_text.put(arena, "path", .{ .string = write_file }); try args_text.put(arena, "content", .{ .string = "plain" }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_text }) == null); var args_both: std.json.ObjectMap = .empty; - try args_both.put(arena, "path", .{ .string = "/srv/f" }); + try args_both.put(arena, "path", .{ .string = write_file }); try args_both.put(arena, "content", .{ .string = "plain" }); try args_both.put(arena, "content_b64", .{ .string = "aGk=" }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_both }) == null); var args_neither: std.json.ObjectMap = .empty; - try args_neither.put(arena, "path", .{ .string = "/srv/f" }); + try args_neither.put(arena, "path", .{ .string = write_file }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_neither }) == null); var args_badtype: std.json.ObjectMap = .empty; - try args_badtype.put(arena, "path", .{ .string = "/srv/f" }); + try args_badtype.put(arena, "path", .{ .string = write_file }); try args_badtype.put(arena, "content", .{ .integer = 7 }); if (checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_badtype })) |d| { try testing.expectEqualStrings("bad_argument_type", d.reason_tag); @@ -1806,7 +1808,7 @@ test "policy: argument-type grammar matches the tool layer" { // Explicit null content counts as absent: content_b64 alone passes. var args_null_text: std.json.ObjectMap = .empty; - try args_null_text.put(arena, "path", .{ .string = "/srv/f" }); + try args_null_text.put(arena, "path", .{ .string = write_file }); try args_null_text.put(arena, "content", Value.null); try args_null_text.put(arena, "content_b64", .{ .string = "aGk=" }); try testing.expect(checkCall(arena, io, undefined, &pol, "write_file", .{ .object = args_null_text }) == null); diff --git a/src/rpc.zig b/src/rpc.zig index da4d0b2..c8ac42e 100644 --- a/src/rpc.zig +++ b/src/rpc.zig @@ -4,6 +4,7 @@ //! in main.zig. const std = @import("std"); +const builtin = @import("builtin"); const Allocator = std.mem.Allocator; const Io = std.Io; const Value = std.json.Value; @@ -618,12 +619,21 @@ test "rpc notification tools/call executes the method and answers 202" { defer tmp.cleanup(); var buf: [std.fs.max_path_bytes]u8 = undefined; const root = try arena.dupe(u8, buf[0..try tmp.dir.realPathFile(io, ".", &buf)]); + // JSON strings cannot contain raw Windows backslashes (notably \t). + // Forward slashes are valid Windows paths and keep the request literal. + const json_root = if (builtin.os.tag == .windows) blk: { + const p = try arena.dupe(u8, root); + for (p) |*c| if (c.* == '\\') { + c.* = '/'; + }; + break :blk p; + } else root; // write_file as a notification: the file must materialize even though // the transport answer is 202 with an empty body (the result is // discarded, the execution is not). Answering 202 before the dispatch // would report success with the method never executed. - const note_write = try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{{\"name\":\"write_file\",\"arguments\":{{\"path\":\"{s}/note.txt\",\"content_b64\":\"aGk=\"}}}}}}", .{root}); + const note_write = try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{{\"name\":\"write_file\",\"arguments\":{{\"path\":\"{s}/note.txt\",\"content_b64\":\"aGk=\"}}}}}}", .{json_root}); const note_w = try handleRpc(arena, io, &cfg, note_write); try std.testing.expectEqual(@as(u16, 202), note_w.status); try std.testing.expectEqual(@as(usize, 0), note_w.body.len); @@ -631,7 +641,10 @@ test "rpc notification tools/call executes the method and answers 202" { try std.testing.expectEqualStrings("hi", written); // exec as a notification: side effect observable, answer still 202. - const note_exec = try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{{\"name\":\"exec\",\"arguments\":{{\"argv\":[\"/bin/sh\",\"-c\",\"printf NOTIFIED > {s}/n2.txt\"]}}}}}}", .{root}); + const note_exec = if (builtin.os.tag == .windows) + try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{{\"name\":\"exec\",\"arguments\":{{\"argv\":[\"cmd.exe\",\"/d\",\"/c\",\"echo NOTIFIED>{s}/n2.txt\"]}}}}}}", .{json_root}) + else + try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{{\"name\":\"exec\",\"arguments\":{{\"argv\":[\"/bin/sh\",\"-c\",\"printf NOTIFIED > {s}/n2.txt\"]}}}}}}", .{root}); const note_e = try handleRpc(arena, io, &cfg, note_exec); try std.testing.expectEqual(@as(u16, 202), note_e.status); try std.testing.expectEqual(@as(usize, 0), note_e.body.len); @@ -645,7 +658,7 @@ test "rpc notification tools/call executes the method and answers 202" { // Regular requests are not regressed: same write_file WITH an id // answers 200 with a serialized result. - const req_write = try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{{\"name\":\"write_file\",\"arguments\":{{\"path\":\"{s}/note2.txt\",\"content_b64\":\"eW8=\"}}}}}}", .{root}); + const req_write = try std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{{\"name\":\"write_file\",\"arguments\":{{\"path\":\"{s}/note2.txt\",\"content_b64\":\"eW8=\"}}}}}}", .{json_root}); const req_w = try handleRpc(arena, io, &cfg, req_write); try std.testing.expectEqual(@as(u16, 200), req_w.status); const req_parsed = try std.json.parseFromSliceLeaky(Value, arena, req_w.body, .{}); From 8a110e3f0bbbbeb2cbca1e07381db413b96882eb Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 19:46:25 +0000 Subject: [PATCH 04/10] fix: initialize Winsock and canonicalize test roots --- ci/test_copy_path.py | 2 +- ci/test_regressions.py | 2 +- src/main.zig | 1 + src/os/net.zig | 28 +++++++++++++++++++++++----- src/rpc.zig | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/ci/test_copy_path.py b/ci/test_copy_path.py index 714dec9..9d63544 100644 --- a/ci/test_copy_path.py +++ b/ci/test_copy_path.py @@ -127,7 +127,7 @@ def test_features_and_manifest(self): class CopyPolicyAudit(unittest.TestCase): def test_dual_roots_descendant_denies_and_audit(self): with tempfile.TemporaryDirectory(prefix='mcp-copy-policy-') as temp: - root = Path(temp) + root = Path(temp).resolve() src, dst = root / 'read', root / 'write' src.mkdir(); dst.mkdir() (src / 'a').write_text('never log this content') diff --git a/ci/test_regressions.py b/ci/test_regressions.py index 2c87c53..5e3b8dd 100644 --- a/ci/test_regressions.py +++ b/ci/test_regressions.py @@ -108,7 +108,7 @@ class Node: def __init__(self, **config): self.temp = tempfile.TemporaryDirectory(prefix='mcp-regress-') - self.root = Path(self.temp.name) + self.root = Path(self.temp.name).resolve() self.secret = secrets.token_hex(24) auth = self.root / 'auth-fixture' auth.write_text(self.secret) diff --git a/src/main.zig b/src/main.zig index f08e4a2..e02621d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -59,6 +59,7 @@ pub fn main(init: std.process.Init.Minimal) !void { var threaded = std.Io.Threaded.init(std.heap.page_allocator, .{ .environ = env_state.process_environ }); defer threaded.deinit(); const io = threaded.io(); + try os.net.ensureNetworking(io); // Darwin CLOEXEC fork gate (os/proc.zig): install the pthread_atfork // hooks before any connection/session thread can race pipe creation diff --git a/src/os/net.zig b/src/os/net.zig index 004eb52..b53fc6c 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -252,6 +252,7 @@ pub const ListenError = error{ AddressInUse, ListenFailed }; /// does not block a fresh listener. pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net.Server { if (comptime builtin.os.tag == .windows) { + ensureNetworking(io) catch return error.ListenFailed; return addr.listen(io, .{}) catch |err| switch (err) { error.AddressInUse => error.AddressInUse, else => error.ListenFailed, @@ -301,10 +302,7 @@ pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net. pub fn setListenNonBlocking(handle: Handle) SetTimeoutError!void { if (comptime builtin.os.tag == .windows) { var one: c_ulong = 1; - if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) { - if (builtin.is_test) std.debug.print("FIONBIO WSA error {d}\n", .{winsock.WSAGetLastError()}); - return error.SocketOptionFailed; - } + if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) return error.SocketOptionFailed; return; } if (comptime builtin.os.tag == .linux) { @@ -351,9 +349,29 @@ const winsock = struct { pub const FIONBIO: c_long = -2147195266; // 0x8004667E as signed long pub extern "ws2_32" fn accept(s: std.os.windows.HANDLE, addr: ?*ws.sockaddr, addrlen: ?*c_int) std.os.windows.HANDLE; pub extern "ws2_32" fn ioctlsocket(s: std.os.windows.HANDLE, cmd: c_long, argp: *c_ulong) c_int; - pub extern "ws2_32" fn WSAGetLastError() c_int; + pub extern "ws2_32" fn WSAStartup(wVersionRequired: u16, lpWSAData: *anyopaque) c_int; }; +var winsock_mutex: std.Io.Mutex = .init; +var winsock_ready = false; + +/// Raw winsock calls need their own process reference. std's Io listener +/// does not establish one for this module. Keep the reference until process +/// exit; Windows reclaims it with the process and concurrent std users stay +/// valid even if they release their own reference. +pub fn ensureNetworking(io: std.Io) error{SocketOptionFailed}!void { + if (comptime builtin.os.tag != .windows) return; + winsock_mutex.lockUncancelable(io); + defer winsock_mutex.unlock(io); + if (winsock_ready) return; + // WSADATA is 400 bytes on 64-bit Windows. The oversized aligned buffer + // avoids duplicating its version-dependent layout; only the return code + // is needed here. + var wsa_data: [512]u8 align(8) = undefined; + if (winsock.WSAStartup(0x0202, &wsa_data) != 0) return error.SocketOptionFailed; + winsock_ready = true; +} + /// Peer address from a winsock sockaddr (accept-derived). fn ipFromWsa(storage: *const std.os.windows.ws2_32.sockaddr.storage) std.Io.net.IpAddress { const ws = std.os.windows.ws2_32; diff --git a/src/rpc.zig b/src/rpc.zig index c8ac42e..b71935d 100644 --- a/src/rpc.zig +++ b/src/rpc.zig @@ -649,7 +649,7 @@ test "rpc notification tools/call executes the method and answers 202" { try std.testing.expectEqual(@as(u16, 202), note_e.status); try std.testing.expectEqual(@as(usize, 0), note_e.body.len); const n2 = try tmp.dir.readFileAlloc(io, "n2.txt", arena, .limited(64)); - try std.testing.expectEqualStrings("NOTIFIED", n2); + try std.testing.expectEqualStrings("NOTIFIED", std.mem.trimEnd(u8, n2, "\r\n")); // Notifications with no executor side effects keep the 202/empty shape. const note_ping = try handleRpc(arena, io, &cfg, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}"); From c4959d45164216cd6153212423e23bc5195251c8 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 20:00:39 +0000 Subject: [PATCH 05/10] test: trace remaining native path and socket failures --- src/os/net.zig | 6 +++++- src/os/secure.zig | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/os/net.zig b/src/os/net.zig index b53fc6c..d275e54 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -302,7 +302,10 @@ pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net. pub fn setListenNonBlocking(handle: Handle) SetTimeoutError!void { if (comptime builtin.os.tag == .windows) { var one: c_ulong = 1; - if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) return error.SocketOptionFailed; + if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) { + if (builtin.is_test) std.debug.print("FIONBIO WSA error {d}, handle type {s}\n", .{ winsock.WSAGetLastError(), @typeName(@TypeOf(handle)) }); + return error.SocketOptionFailed; + } return; } if (comptime builtin.os.tag == .linux) { @@ -349,6 +352,7 @@ const winsock = struct { pub const FIONBIO: c_long = -2147195266; // 0x8004667E as signed long pub extern "ws2_32" fn accept(s: std.os.windows.HANDLE, addr: ?*ws.sockaddr, addrlen: ?*c_int) std.os.windows.HANDLE; pub extern "ws2_32" fn ioctlsocket(s: std.os.windows.HANDLE, cmd: c_long, argp: *c_ulong) c_int; + pub extern "ws2_32" fn WSAGetLastError() c_int; pub extern "ws2_32" fn WSAStartup(wVersionRequired: u16, lpWSAData: *anyopaque) c_int; }; diff --git a/src/os/secure.zig b/src/os/secure.zig index 5117978..385f0b5 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -641,12 +641,18 @@ fn openPrefixDarwin(io: Io, prefix: []const u8) OpenError!fd_t { fn postCheckDarwin(fd: fd_t, pin: Pin) error{ PathDenied, PathEscape }!void { var buf: [std.fs.max_path_bytes]u8 = undefined; - const n = fdRealPathDarwin(fd, &buf) catch return error.PathEscape; + const n = fdRealPathDarwin(fd, &buf) catch |err| { + if (builtin.is_test) std.debug.print("F_GETPATH failed: {s}, canon='{s}'\n", .{ @errorName(err), pin.canon }); + return error.PathEscape; + }; const real = buf[0..n]; for (pin.deny) |d| { if (pathUnderPrefix(real, d)) return error.PathDenied; } - if (!std.mem.eql(u8, real, pin.canon)) return error.PathEscape; + if (!std.mem.eql(u8, real, pin.canon)) { + if (builtin.is_test) std.debug.print("Darwin pin mismatch: real='{s}', canon='{s}'\n", .{ real, pin.canon }); + return error.PathEscape; + } } fn openPinnedDarwin(io: Io, pin: Pin, access: Access, wopts: WriteOpts) OpenError!fd_t { From dd4120d1dda9a19a8d0c4dae5e4064e6f5a1385d Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 20:16:57 +0000 Subject: [PATCH 06/10] fix: use the native Windows AFD accept path --- ci/test_copy_path.py | 9 ++++-- src/hub.zig | 74 ++++++++++++++------------------------------ src/main.zig | 1 - src/os/net.zig | 43 +++---------------------- src/os/secure.zig | 4 +-- 5 files changed, 37 insertions(+), 94 deletions(-) diff --git a/ci/test_copy_path.py b/ci/test_copy_path.py index 9d63544..37bd239 100644 --- a/ci/test_copy_path.py +++ b/ci/test_copy_path.py @@ -20,8 +20,11 @@ def setUp(self): self.root = self.node.root def call(self, **args): - return self.node.tool('copy_path', dict(source=str(self.root / 'src'), - destination=str(self.root / 'dst'), **args)) + result = self.node.tool('copy_path', dict(source=str(self.root / 'src'), + destination=str(self.root / 'dst'), **args)) + if result.get('error') == 'PathEscape': + print(self.node.logs(), flush=True) + return result def clean_stage(self): self.assertFalse(list(self.root.glob('.mcpnz-copy-*'))) @@ -147,6 +150,8 @@ def test_dual_roots_descendant_denies_and_audit(self): node.secret = token.read_text() try: ok = node.tool('copy_path', {'source': str(src / 'a'), 'destination': str(dst / 'a')}) + if ok.get('error') == 'PathEscape': + print(node.logs(), flush=True) self.assertTrue(ok['ok'], ok) denied = node.tool('copy_path', {'source': str(dst / 'a'), 'destination': str(dst / 'b')}) self.assertEqual(denied['error'], 'PolicyDenied') diff --git a/src/hub.zig b/src/hub.zig index d1948b9..1a41225 100644 --- a/src/hub.zig +++ b/src/hub.zig @@ -1128,37 +1128,20 @@ fn acceptLoop(hub: *Hub, server: *Io.net.Server) void { } continue; } - // Windows: WSAPoll the listener and the pre-created wake channel: - // stop() lands one byte on it, so retracting this thread - // never needs a fresh socket. The 1 s tick is a fallback only. - var pfds: [2]os.net.WSAPOLLFD = .{ - .{ .fd = server.socket.handle, .events = os.net.POLLRDNORM, .revents = 0 }, - .{ .fd = if (hub.wake_recv) |*st| st.socket.handle else server.socket.handle, .events = if (hub.wake_recv != null) os.net.POLLRDNORM else 0, .revents = 0 }, - }; - if (os.net.wsaPoll(&pfds, 1000) < 0) { - if (!hub.stopping.load(.acquire)) os.sleepMs(ACCEPT_BACKOFF_MS); + // Windows std sockets are AFD handles, not Winsock SOCKETs. + // Use the matching backend for accept; stop() self-connects to + // release this blocking call before joining the thread. + const stream = server.accept(hub.io) catch |err| { + if (hub.stopping.load(.acquire)) return; + std.debug.print("hub accept failed: {s}\n", .{@errorName(err)}); + os.sleepMs(ACCEPT_BACKOFF_MS); continue; + }; + if (hub.stopping.load(.acquire)) { + stream.close(hub.io); + return; } - if (hub.stopping.load(.acquire)) return; - if (hub.wake_recv != null and pfds[1].revents != 0) return; // wake byte - if (pfds[0].revents & os.net.POLLRDNORM == 0) { - if (pfds[0].revents & (os.net.POLLERR | os.net.POLLHUP | os.net.POLLNVAL) != 0) { - std.debug.print("hub listener poll failed\n", .{}); - os.sleepMs(ACCEPT_BACKOFF_MS); - } - continue; // tick - } - // Raw non-blocking accepts (FIONBIO listener, set in start): a - // queue drained between poll and accept answers null instead of - // parking this thread beyond the wake's reach. std's - // Io.net accept is never used on this socket. - while (os.net.acceptNonBlocking(server.socket.handle)) |stream| { - if (hub.stopping.load(.acquire)) { - stream.close(hub.io); - return; - } - adopt(hub, stream); - } + adopt(hub, stream); } } @@ -1186,25 +1169,20 @@ pub fn start(hub: *Hub, server: *Io.net.Server) !void { } } const addr = try Io.net.IpAddress.parse(ep.host, ep.port); - // The accept loop's wake channel exists before any thread: stop() must - // never depend on allocating a fresh socket under teardown. POSIX uses - // a pipe; Windows a pre-connected loopback pair. - if (comptime builtin.os.tag == .windows) { - const loop = try Io.net.IpAddress.parse("127.0.0.1", 0); - var wls = try os.net.listenTcp(hub.io, loop); - defer wls.deinit(hub.io); // the established pair outlives its listener - hub.wake_send = try wls.socket.address.connect(hub.io, .{ .mode = .stream }); - hub.wake_recv = try wls.accept(hub.io); - } else { + // POSIX uses a pre-created pipe because acceptLoop polls it with the + // listener. Windows uses std's blocking AFD accept and stop() releases + // it with a loopback self-connect. + if (comptime builtin.os.tag != .windows) { const pair = proc.createWakePipe() orelse return error.WakePipeFailed; hub.wake_rfd = pair[0]; hub.wake_wfd = pair[1]; } server.* = try os.net.listenTcp(hub.io, addr); // no SO_REUSEPORT, see os.net server_opt = server; - // The accept loop polls and then accepts raw/non-blocking: the listen - // fd must not block between poll and accept (queue drained by a reset). - try os.net.setListenNonBlocking(server.socket.handle); + if (comptime builtin.os.tag != .windows) { + // Drain-after-poll must never park when a reset empties the queue. + try os.net.setListenNonBlocking(server.socket.handle); + } const a = try std.Thread.spawn(.{}, acceptLoop, .{ hub, server }); hub.accept_thread = a; const p = try std.Thread.spawn(.{}, pinger, .{hub}); @@ -1224,15 +1202,11 @@ pub fn stop(hub: *Hub, server: ?*Io.net.Server) void { hub.stopping.store(true, .release); hub.mutex.unlock(hub.io); hub.killAll(); - // Wake the parked accept without allocating anything at teardown time. - // POSIX: one byte into the pre-created wake pipe (non-blocking; a full - // pipe means "already signaled"). Windows: one byte over the - // pre-connected loopback pair; the self-connect remains only as the - // fallback for a hub whose wake-channel creation failed. + // POSIX writes to the pre-created wake pipe. Windows releases std's + // blocking AFD accept with one loopback connection; acceptLoop closes + // that stream after observing `stopping`. if (comptime builtin.os.tag == .windows) { - if (hub.wake_send) |*st| { - os.net.socketWriteAll(st.socket.handle, "!", 1000) catch {}; - } else if (server) |srv| { + if (server) |srv| { if (srv.socket.address.connect(hub.io, .{ .mode = .stream })) |st2| { st2.close(hub.io); } else |_| {} diff --git a/src/main.zig b/src/main.zig index e02621d..f08e4a2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -59,7 +59,6 @@ pub fn main(init: std.process.Init.Minimal) !void { var threaded = std.Io.Threaded.init(std.heap.page_allocator, .{ .environ = env_state.process_environ }); defer threaded.deinit(); const io = threaded.io(); - try os.net.ensureNetworking(io); // Darwin CLOEXEC fork gate (os/proc.zig): install the pthread_atfork // hooks before any connection/session thread can race pipe creation diff --git a/src/os/net.zig b/src/os/net.zig index d275e54..0b4a9da 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -252,7 +252,6 @@ pub const ListenError = error{ AddressInUse, ListenFailed }; /// does not block a fresh listener. pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net.Server { if (comptime builtin.os.tag == .windows) { - ensureNetworking(io) catch return error.ListenFailed; return addr.listen(io, .{}) catch |err| switch (err) { error.AddressInUse => error.AddressInUse, else => error.ListenFailed, @@ -301,11 +300,8 @@ pub fn listenTcp(io: std.Io, addr: std.Io.net.IpAddress) ListenError!std.Io.net. /// self-connect wake. pub fn setListenNonBlocking(handle: Handle) SetTimeoutError!void { if (comptime builtin.os.tag == .windows) { - var one: c_ulong = 1; - if (winsock.ioctlsocket(@ptrCast(handle), winsock.FIONBIO, &one) != 0) { - if (builtin.is_test) std.debug.print("FIONBIO WSA error {d}, handle type {s}\n", .{ winsock.WSAGetLastError(), @typeName(@TypeOf(handle)) }); - return error.SocketOptionFailed; - } + // std Windows sockets are AFD handles; FIONBIO accepts only SOCKET. + // Hub.start uses std's blocking AFD accept on this target instead. return; } if (comptime builtin.os.tag == .linux) { @@ -356,26 +352,6 @@ const winsock = struct { pub extern "ws2_32" fn WSAStartup(wVersionRequired: u16, lpWSAData: *anyopaque) c_int; }; -var winsock_mutex: std.Io.Mutex = .init; -var winsock_ready = false; - -/// Raw winsock calls need their own process reference. std's Io listener -/// does not establish one for this module. Keep the reference until process -/// exit; Windows reclaims it with the process and concurrent std users stay -/// valid even if they release their own reference. -pub fn ensureNetworking(io: std.Io) error{SocketOptionFailed}!void { - if (comptime builtin.os.tag != .windows) return; - winsock_mutex.lockUncancelable(io); - defer winsock_mutex.unlock(io); - if (winsock_ready) return; - // WSADATA is 400 bytes on 64-bit Windows. The oversized aligned buffer - // avoids duplicating its version-dependent layout; only the return code - // is needed here. - var wsa_data: [512]u8 align(8) = undefined; - if (winsock.WSAStartup(0x0202, &wsa_data) != 0) return error.SocketOptionFailed; - winsock_ready = true; -} - /// Peer address from a winsock sockaddr (accept-derived). fn ipFromWsa(storage: *const std.os.windows.ws2_32.sockaddr.storage) std.Io.net.IpAddress { const ws = std.os.windows.ws2_32; @@ -410,19 +386,8 @@ pub fn wsaPoll(fds: []WSAPOLLFD, timeout_ms: c_int) c_int { /// CLOEXEC, exactly like std's accept would produce. pub fn acceptNonBlocking(listen_fd: Handle) ?std.Io.net.Stream { if (comptime builtin.os.tag == .windows) { - var storage: std.os.windows.ws2_32.sockaddr.storage = .{ .family = 0 }; - var len: c_int = @sizeOf(std.os.windows.ws2_32.sockaddr.storage); - const s = winsock.accept(@ptrCast(listen_fd), @ptrCast(&storage), &len); - if (s == winsock.invalid) return null; // WSAEWOULDBLOCK: queue drained - // A raw-accepted socket may inherit the listen socket's FIONBIO - // mode; force blocking — link I/O relies on blocking reads with - // software deadlines (see the module doc). - var zero: c_ulong = 0; - _ = winsock.ioctlsocket(s, winsock.FIONBIO, &zero); - return .{ .socket = .{ - .handle = @ptrCast(s), - .address = ipFromWsa(&storage), - } }; + // AFD handles require Server.accept(); hub uses that path directly. + unreachable; } var storage: std.Io.Threaded.PosixAddress = undefined; var len: std.posix.socklen_t = @sizeOf(std.Io.Threaded.PosixAddress); diff --git a/src/os/secure.zig b/src/os/secure.zig index 385f0b5..a03af6f 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -642,7 +642,7 @@ fn openPrefixDarwin(io: Io, prefix: []const u8) OpenError!fd_t { fn postCheckDarwin(fd: fd_t, pin: Pin) error{ PathDenied, PathEscape }!void { var buf: [std.fs.max_path_bytes]u8 = undefined; const n = fdRealPathDarwin(fd, &buf) catch |err| { - if (builtin.is_test) std.debug.print("F_GETPATH failed: {s}, canon='{s}'\n", .{ @errorName(err), pin.canon }); + if (builtin.os.tag == .macos) std.debug.print("F_GETPATH failed: {s}, canon='{s}'\n", .{ @errorName(err), pin.canon }); return error.PathEscape; }; const real = buf[0..n]; @@ -650,7 +650,7 @@ fn postCheckDarwin(fd: fd_t, pin: Pin) error{ PathDenied, PathEscape }!void { if (pathUnderPrefix(real, d)) return error.PathDenied; } if (!std.mem.eql(u8, real, pin.canon)) { - if (builtin.is_test) std.debug.print("Darwin pin mismatch: real='{s}', canon='{s}'\n", .{ real, pin.canon }); + if (builtin.os.tag == .macos) std.debug.print("Darwin pin mismatch: real='{s}', canon='{s}'\n", .{ real, pin.canon }); return error.PathEscape; } } From 2aaa24c138d4f254e01914464fa703e296b32806 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 20:38:40 +0000 Subject: [PATCH 07/10] fix: accept Windows links through the AFD backend --- src/hub.zig | 22 ++++------- src/os/net.zig | 97 ++++++++++++++++++++++++++++++++++++++++++++++- src/os/secure.zig | 1 + 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/src/hub.zig b/src/hub.zig index 1a41225..c1a273f 100644 --- a/src/hub.zig +++ b/src/hub.zig @@ -1128,12 +1128,9 @@ fn acceptLoop(hub: *Hub, server: *Io.net.Server) void { } continue; } - // Windows std sockets are AFD handles, not Winsock SOCKETs. - // Use the matching backend for accept; stop() self-connects to - // release this blocking call before joining the thread. - const stream = server.accept(hub.io) catch |err| { + // Windows AFD cancellation releases the pending accept in stop(). + const stream = os.net.acceptNonBlocking(server.socket.handle) orelse { if (hub.stopping.load(.acquire)) return; - std.debug.print("hub accept failed: {s}\n", .{@errorName(err)}); os.sleepMs(ACCEPT_BACKOFF_MS); continue; }; @@ -1202,15 +1199,10 @@ pub fn stop(hub: *Hub, server: ?*Io.net.Server) void { hub.stopping.store(true, .release); hub.mutex.unlock(hub.io); hub.killAll(); - // POSIX writes to the pre-created wake pipe. Windows releases std's - // blocking AFD accept with one loopback connection; acceptLoop closes - // that stream after observing `stopping`. + // POSIX writes to the pre-created wake pipe. Windows cancels the + // listener's pending AFD accept before joining its thread. if (comptime builtin.os.tag == .windows) { - if (server) |srv| { - if (srv.socket.address.connect(hub.io, .{ .mode = .stream })) |st2| { - st2.close(hub.io); - } else |_| {} - } + if (server) |srv| srv.deinit(hub.io); } else if (hub.wake_wfd) |wfd| { var byte = [_]u8{1}; os.writeAllFd(wfd, &byte) catch {}; @@ -1229,7 +1221,9 @@ pub fn stop(hub: *Hub, server: ?*Io.net.Server) void { // every borrower of hub/cfg/io exits on its own. A deadline here would // only authorize freeing state a live thread still borrows. while (hub.threads.load(.acquire) != 0) os.sleepMs(5); - if (server) |srv| srv.deinit(hub.io); + if (server) |srv| { + if (comptime builtin.os.tag != .windows) srv.deinit(hub.io); + } if (comptime builtin.os.tag == .windows) { if (hub.wake_send) |*st| { st.close(hub.io); diff --git a/src/os/net.zig b/src/os/net.zig index 0b4a9da..73548eb 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -386,8 +386,7 @@ pub fn wsaPoll(fds: []WSAPOLLFD, timeout_ms: c_int) c_int { /// CLOEXEC, exactly like std's accept would produce. pub fn acceptNonBlocking(listen_fd: Handle) ?std.Io.net.Stream { if (comptime builtin.os.tag == .windows) { - // AFD handles require Server.accept(); hub uses that path directly. - unreachable; + return acceptWindowsAfd(listen_fd); } var storage: std.Io.Threaded.PosixAddress = undefined; var len: std.posix.socklen_t = @sizeOf(std.Io.Threaded.PosixAddress); @@ -467,6 +466,100 @@ const max_ioctl_chunk: usize = 1 << 30; const windows = std.os.windows; +fn openSocketAfd(family: windows.ws2_32.ADDRESS_FAMILY) !windows.HANDLE { + var handle: windows.HANDLE = undefined; + var iosb: windows.IO_STATUS_BLOCK = undefined; + const status = windows.ntdll.NtCreateFile( + &handle, + .{ .STANDARD = .{ .RIGHTS = .{ .WRITE_DAC = true }, .SYNCHRONIZE = true }, .GENERIC = .{ .WRITE = true, .READ = true } }, + &.{ .ObjectName = @constCast(&windows.UNICODE_STRING.init(windows.AFD.DEVICE_NAME ++ .{ '\\', 'E', 'n', 'd', 'p', 'o', 'i', 'n', 't' })) }, + &iosb, + null, + .{}, + .{ .READ = true, .WRITE = true }, + .OPEN_IF, + .{ .IO = .ASYNCHRONOUS }, + &windows.AFD.OPEN_PACKET.FULL_EA_INFORMATION{ .Value = .{ + .EndpointType = .{ .CONNECTIONLESS = false, .MESSAGEMODE = false, .RAW = false }, + .GroupID = 0, + .AddressFamily = family, + .SocketType = windows.ws2_32.SOCK.STREAM, + .Protocol = windows.ws2_32.IPPROTO.TCP, + .TransportDeviceNameLength = 0, + .TransportDeviceName = undefined, + } }, + @sizeOf(windows.AFD.OPEN_PACKET.FULL_EA_INFORMATION), + ); + if (status != .SUCCESS) { + if (builtin.is_test) std.debug.print("AFD endpoint NTSTATUS 0x{x}\n", .{@intFromEnum(status)}); + return error.SocketOptionFailed; + } + return handle; +} + +/// AFD's three-step accept, matching Zig 0.16's Threaded backend. This keeps +/// accepted handles compatible with the AFD read/write/shutdown helpers. +fn acceptWindowsAfd(listen_fd: Handle) ?std.Io.net.Stream { + const Storage = extern struct { + info: windows.AFD.LISTEN_RESPONSE_INFO, + remote: std.Io.Threaded.PosixAddress, + }; + var storage: Storage = undefined; + _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.WAIT_FOR_LISTEN, null, std.mem.asBytes(&storage), null) catch |err| { + if (builtin.is_test) std.debug.print("AFD wait failed: {s}\n", .{@errorName(err)}); + return null; + }; + const accepted = openSocketAfd(storage.remote.any.family) catch return null; + var info = windows.AFD.ACCEPT_INFO{ + .UseSAN = .FALSE, + .Sequence = storage.info.Sequence, + .AcceptHandle = accepted, + }; + _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.ACCEPT, std.mem.asBytes(&info), null, null) catch |err| { + if (builtin.is_test) std.debug.print("AFD accept failed: {s}\n", .{@errorName(err)}); + windows.CloseHandle(accepted); + return null; + }; + return .{ .socket = .{ .handle = accepted, .address = std.Io.Threaded.addressFromPosix(&storage.remote) } }; +} + +fn afdSocketIoOut(handle: windows.HANDLE, code: windows.CTL_CODE, in: ?[]const u8, out: ?[]u8, timeout_ms: ?u64) !usize { + var event: windows.HANDLE = undefined; + switch (windows.ntdll.NtCreateEvent(&event, windows.ACCESS_MASK.Specific.Event.ALL_ACCESS, null, .Notification, .FALSE)) { + .SUCCESS => {}, + else => |status| return windows.unexpectedStatus(status), + } + defer windows.CloseHandle(event); + var iosb: windows.IO_STATUS_BLOCK = undefined; + const status = windows.ntdll.NtDeviceIoControlFile(handle, event, null, null, &iosb, code, + if (in) |b| b.ptr else null, if (in) |b| @intCast(b.len) else 0, + if (out) |b| b.ptr else null, if (out) |b| @intCast(b.len) else 0); + switch (status) { + .SUCCESS => return iosb.Information, + .PENDING => {}, + else => |s| { + if (builtin.is_test) std.debug.print("AFD ioctl NTSTATUS 0x{x}\n", .{@intFromEnum(s)}); + return windows.unexpectedStatus(s); + }, + } + const timeout: ?windows.LARGE_INTEGER = if (timeout_ms) |ms| -@as(windows.LARGE_INTEGER, @intCast(ms * 10_000)) else null; + switch (windows.ntdll.NtWaitForSingleObject(event, .FALSE, if (timeout) |*t| t else null)) { + .SUCCESS => {}, + .TIMEOUT => { + var cancel_iosb: windows.IO_STATUS_BLOCK = undefined; + _ = windows.ntdll.NtCancelIoFileEx(handle, &iosb, &cancel_iosb); + _ = windows.ntdll.NtWaitForSingleObject(event, .FALSE, null); + return error.Timeout; + }, + else => |s| return windows.unexpectedStatus(s), + } + if (iosb.u.Status != .SUCCESS) { + if (builtin.is_test) std.debug.print("AFD completion NTSTATUS 0x{x}\n", .{@intFromEnum(iosb.u.Status)}); + return windows.unexpectedStatus(iosb.u.Status); + } + return iosb.Information; +} + /// Issue one overlapped AFD ioctl against a socket handle and wait for it /// with an optional deadline. Returns the number of transferred bytes /// (`IO_STATUS_BLOCK.Information`). diff --git a/src/os/secure.zig b/src/os/secure.zig index a03af6f..5de792f 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -605,6 +605,7 @@ fn fdRealPathDarwin(fd: fd_t, buf: *[std.fs.max_path_bytes]u8) !usize { comptime if (std.fs.max_path_bytes < darwin_max_path_len) @compileError("F_GETPATH buffer below MAXPATHLEN: bump the call-site buffers"); const rc = fcntl(@intCast(fd), F_GETPATH, buf[0..].ptr); + if (builtin.os.tag == .macos) std.debug.print("F_GETPATH raw: fd={d}, rc={d}, errno={d}\n", .{ fd, rc, std.c._errno().* }); return decodeGetPath(rc, buf); } From 06413f8cba8b9a435a7f27f8ef1988a86070ed2f Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 20:56:33 +0000 Subject: [PATCH 08/10] test: align hub shutdown with the AFD backend --- src/hub.zig | 7 +++++-- src/os/net.zig | 4 +--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hub.zig b/src/hub.zig index c1a273f..c4d3d2a 100644 --- a/src/hub.zig +++ b/src/hub.zig @@ -2030,8 +2030,11 @@ test "hub stop retracts infrastructure threads and closes the listener" { try start(&fx.hub, &server); try testing.expect(fx.hub.accept_thread != null); try testing.expect(fx.hub.pinger_thread != null); - // The accept loop's wake does not depend on allocating a socket. - try testing.expect(fx.hub.wake_wfd != null); + // POSIX has a dedicated wake pipe; Windows closes the AFD listener + // to retract accept during stop. + if (comptime builtin.os.tag != .windows) { + try testing.expect(fx.hub.wake_wfd != null); + } const bound = server.socket.address; // The startup-rollback entry point: threads join, the listener closes. diff --git a/src/os/net.zig b/src/os/net.zig index 73548eb..bc1700a 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -531,9 +531,7 @@ fn afdSocketIoOut(handle: windows.HANDLE, code: windows.CTL_CODE, in: ?[]const u } defer windows.CloseHandle(event); var iosb: windows.IO_STATUS_BLOCK = undefined; - const status = windows.ntdll.NtDeviceIoControlFile(handle, event, null, null, &iosb, code, - if (in) |b| b.ptr else null, if (in) |b| @intCast(b.len) else 0, - if (out) |b| b.ptr else null, if (out) |b| @intCast(b.len) else 0); + const status = windows.ntdll.NtDeviceIoControlFile(handle, event, null, null, &iosb, code, if (in) |b| b.ptr else null, if (in) |b| @intCast(b.len) else 0, if (out) |b| b.ptr else null, if (out) |b| @intCast(b.len) else 0); switch (status) { .SUCCESS => return iosb.Information, .PENDING => {}, From 528cb72c2042f2bdb295ce4679cc80ccb2b06fbe Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 21:08:59 +0000 Subject: [PATCH 09/10] fix: honor Darwin fcntl ABI and portable benchmark imports --- bench/harness/footprint_probe.py | 2 +- src/os/secure.zig | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bench/harness/footprint_probe.py b/bench/harness/footprint_probe.py index 06fbcb2..818e8d7 100755 --- a/bench/harness/footprint_probe.py +++ b/bench/harness/footprint_probe.py @@ -17,7 +17,7 @@ import subprocess import sys -sys.path.insert(0, __file__.rsplit("/", 1)[0]) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import probe_lib as pl # noqa: E402 diff --git a/src/os/secure.zig b/src/os/secure.zig index 5de792f..c454456 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -579,7 +579,6 @@ fn openPinnedLinux(io: Io, pin: Pin, access: Access, wopts: WriteOpts) OpenError /// fcntl(2) with F_GETPATH (Darwin): writes the vnode path of `fd`. const F_GETPATH: c_int = 50; -extern "c" fn fcntl(fd: c_int, cmd: c_int, arg: ?[*]u8) callconv(.c) c_int; extern "c" fn mkdirat(dirfd: c_int, path: [*:0]const u8, mode: std.c.mode_t) callconv(.c) c_int; @@ -604,8 +603,8 @@ fn decodeGetPath(rc: c_int, buf: []const u8) error{PathEscape}!usize { fn fdRealPathDarwin(fd: fd_t, buf: *[std.fs.max_path_bytes]u8) !usize { comptime if (std.fs.max_path_bytes < darwin_max_path_len) @compileError("F_GETPATH buffer below MAXPATHLEN: bump the call-site buffers"); - const rc = fcntl(@intCast(fd), F_GETPATH, buf[0..].ptr); - if (builtin.os.tag == .macos) std.debug.print("F_GETPATH raw: fd={d}, rc={d}, errno={d}\n", .{ fd, rc, std.c._errno().* }); + const rc = std.c.fcntl(@intCast(fd), F_GETPATH, buf[0..].ptr); + if (builtin.os.tag == .macos and rc != 0) std.debug.print("F_GETPATH raw: fd={d}, rc={d}, errno={d}\n", .{ fd, rc, std.c._errno().* }); return decodeGetPath(rc, buf); } From a3b95d7b9c30cf4c4b8ea9fe519d0fd4d0639be2 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Sat, 24 Oct 2026 21:24:12 +0000 Subject: [PATCH 10/10] fix: complete native platform cleanup --- bench/harness/probe_lib.py | 2 +- ci/test_copy_path.py | 9 ++------- src/os/net.zig | 19 ++++--------------- src/os/secure.zig | 11 ++--------- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/bench/harness/probe_lib.py b/bench/harness/probe_lib.py index f8ccad2..5eeceab 100755 --- a/bench/harness/probe_lib.py +++ b/bench/harness/probe_lib.py @@ -22,7 +22,7 @@ import threading import time -sys.path.insert(0, __file__.rsplit("/", 2)[0] + "/lib") +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lib")) import stats # noqa: E402 PROC_VERSION = "2024-11-05" diff --git a/ci/test_copy_path.py b/ci/test_copy_path.py index 37bd239..92c55be 100644 --- a/ci/test_copy_path.py +++ b/ci/test_copy_path.py @@ -20,11 +20,8 @@ def setUp(self): self.root = self.node.root def call(self, **args): - result = self.node.tool('copy_path', dict(source=str(self.root / 'src'), - destination=str(self.root / 'dst'), **args)) - if result.get('error') == 'PathEscape': - print(self.node.logs(), flush=True) - return result + return self.node.tool('copy_path', dict(source=str(self.root / 'src'), + destination=str(self.root / 'dst'), **args)) def clean_stage(self): self.assertFalse(list(self.root.glob('.mcpnz-copy-*'))) @@ -150,8 +147,6 @@ def test_dual_roots_descendant_denies_and_audit(self): node.secret = token.read_text() try: ok = node.tool('copy_path', {'source': str(src / 'a'), 'destination': str(dst / 'a')}) - if ok.get('error') == 'PathEscape': - print(node.logs(), flush=True) self.assertTrue(ok['ok'], ok) denied = node.tool('copy_path', {'source': str(dst / 'a'), 'destination': str(dst / 'b')}) self.assertEqual(denied['error'], 'PolicyDenied') diff --git a/src/os/net.zig b/src/os/net.zig index bc1700a..058f1df 100644 --- a/src/os/net.zig +++ b/src/os/net.zig @@ -491,7 +491,6 @@ fn openSocketAfd(family: windows.ws2_32.ADDRESS_FAMILY) !windows.HANDLE { @sizeOf(windows.AFD.OPEN_PACKET.FULL_EA_INFORMATION), ); if (status != .SUCCESS) { - if (builtin.is_test) std.debug.print("AFD endpoint NTSTATUS 0x{x}\n", .{@intFromEnum(status)}); return error.SocketOptionFailed; } return handle; @@ -505,18 +504,14 @@ fn acceptWindowsAfd(listen_fd: Handle) ?std.Io.net.Stream { remote: std.Io.Threaded.PosixAddress, }; var storage: Storage = undefined; - _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.WAIT_FOR_LISTEN, null, std.mem.asBytes(&storage), null) catch |err| { - if (builtin.is_test) std.debug.print("AFD wait failed: {s}\n", .{@errorName(err)}); - return null; - }; + _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.WAIT_FOR_LISTEN, null, std.mem.asBytes(&storage), null) catch return null; const accepted = openSocketAfd(storage.remote.any.family) catch return null; var info = windows.AFD.ACCEPT_INFO{ .UseSAN = .FALSE, .Sequence = storage.info.Sequence, .AcceptHandle = accepted, }; - _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.ACCEPT, std.mem.asBytes(&info), null, null) catch |err| { - if (builtin.is_test) std.debug.print("AFD accept failed: {s}\n", .{@errorName(err)}); + _ = afdSocketIoOut(listen_fd, windows.IOCTL.AFD.ACCEPT, std.mem.asBytes(&info), null, null) catch { windows.CloseHandle(accepted); return null; }; @@ -535,10 +530,7 @@ fn afdSocketIoOut(handle: windows.HANDLE, code: windows.CTL_CODE, in: ?[]const u switch (status) { .SUCCESS => return iosb.Information, .PENDING => {}, - else => |s| { - if (builtin.is_test) std.debug.print("AFD ioctl NTSTATUS 0x{x}\n", .{@intFromEnum(s)}); - return windows.unexpectedStatus(s); - }, + else => |s| return windows.unexpectedStatus(s), } const timeout: ?windows.LARGE_INTEGER = if (timeout_ms) |ms| -@as(windows.LARGE_INTEGER, @intCast(ms * 10_000)) else null; switch (windows.ntdll.NtWaitForSingleObject(event, .FALSE, if (timeout) |*t| t else null)) { @@ -551,10 +543,7 @@ fn afdSocketIoOut(handle: windows.HANDLE, code: windows.CTL_CODE, in: ?[]const u }, else => |s| return windows.unexpectedStatus(s), } - if (iosb.u.Status != .SUCCESS) { - if (builtin.is_test) std.debug.print("AFD completion NTSTATUS 0x{x}\n", .{@intFromEnum(iosb.u.Status)}); - return windows.unexpectedStatus(iosb.u.Status); - } + if (iosb.u.Status != .SUCCESS) return windows.unexpectedStatus(iosb.u.Status); return iosb.Information; } diff --git a/src/os/secure.zig b/src/os/secure.zig index c454456..060c6a8 100644 --- a/src/os/secure.zig +++ b/src/os/secure.zig @@ -604,7 +604,6 @@ fn fdRealPathDarwin(fd: fd_t, buf: *[std.fs.max_path_bytes]u8) !usize { comptime if (std.fs.max_path_bytes < darwin_max_path_len) @compileError("F_GETPATH buffer below MAXPATHLEN: bump the call-site buffers"); const rc = std.c.fcntl(@intCast(fd), F_GETPATH, buf[0..].ptr); - if (builtin.os.tag == .macos and rc != 0) std.debug.print("F_GETPATH raw: fd={d}, rc={d}, errno={d}\n", .{ fd, rc, std.c._errno().* }); return decodeGetPath(rc, buf); } @@ -641,18 +640,12 @@ fn openPrefixDarwin(io: Io, prefix: []const u8) OpenError!fd_t { fn postCheckDarwin(fd: fd_t, pin: Pin) error{ PathDenied, PathEscape }!void { var buf: [std.fs.max_path_bytes]u8 = undefined; - const n = fdRealPathDarwin(fd, &buf) catch |err| { - if (builtin.os.tag == .macos) std.debug.print("F_GETPATH failed: {s}, canon='{s}'\n", .{ @errorName(err), pin.canon }); - return error.PathEscape; - }; + const n = fdRealPathDarwin(fd, &buf) catch return error.PathEscape; const real = buf[0..n]; for (pin.deny) |d| { if (pathUnderPrefix(real, d)) return error.PathDenied; } - if (!std.mem.eql(u8, real, pin.canon)) { - if (builtin.os.tag == .macos) std.debug.print("Darwin pin mismatch: real='{s}', canon='{s}'\n", .{ real, pin.canon }); - return error.PathEscape; - } + if (!std.mem.eql(u8, real, pin.canon)) return error.PathEscape; } fn openPinnedDarwin(io: Io, pin: Pin, access: Access, wopts: WriteOpts) OpenError!fd_t {