From ae4e339b84c1404429995f6992397b59def9be38 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 01:47:55 +0900 Subject: [PATCH 1/6] refactor(delib-1c): cErrnoToWasi reads platform.pfdErrno() threadlocal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third step of the W46 un-link-libc migration. Cut the last direct dependency on `std.c._errno()` from the WASI error-reporting path. - Add `platform.pfd_last_errno` (threadlocal `std.posix.E`) + public readers `pfdErrno()` and `syncErrnoFromLibC()`. - Linux arms of `pfd{Write,Read,Pread,Pwrite,Seek,Close,Fsync,Dup, MkdirAt,UnlinkAt,RenameAt,ReadlinkAt}` now set `pfd_last_errno` directly from the syscall return value (no more `std.c._errno().*` writes). Windows arms set it to `.IO` on failure. - Mac/BSD arms route std.c.* results through `cResultAsIsize`/ `cResultAsI32`/`cResultAsI64` helpers that call `syncErrnoFromLibC()` on failure. - `cErrnoToWasi()` now reads `platform.pfdErrno()` instead of `std.c._errno().*`. The WASI mapping table is unchanged. - Non-pfd `std.c.*` call sites in wasi.zig (`fdatasync`, `fsync`, `fcntl`/SETFL, `ftruncate`, `symlinkat`, `linkat`, `futimens`) now have per-platform `switch (comptime builtin.os.tag)` blocks: Linux uses `std.os.linux.*` direct syscalls (sets `pfd_last_errno` inline), Mac/BSD keeps `std.c.*` (calls `syncErrnoFromLibC()` on failure). After this commit the only remaining obstacles to flipping `link_libc = false` are the `std.c.getenv` in `platform.zig` (Phase 1e) and the `std.c.write` in `trace.zig` (Phase 1d). Several `std.c.Stat`/`std.c.fstatat` / `std.c.utimensat` sites remain but they are already inside `if (builtin.os.tag == .linux) {…} else` branches so Mac-only analysis is already gated. Verified: - `zig build test` (Mac aarch64) — all pass. - `zig build -Dtarget=x86_64-linux-gnu` / `-Dtarget=x86_64-windows-gnu` both clean. - `bash test/realworld/run_compat.sh` (Mac ReleaseSafe) — PASS: 50, FAIL: 0. --- src/platform.zig | 111 ++++++++++++++++++++++++++++++------------- src/wasi.zig | 121 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 169 insertions(+), 63 deletions(-) diff --git a/src/platform.zig b/src/platform.zig index ea7e18dc..e13b212a 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -115,17 +115,33 @@ const FILE_END: windows.DWORD = 2; const ERROR_HANDLE_EOF: windows.DWORD = 38; const ERROR_BROKEN_PIPE: windows.DWORD = 109; -// Linux direct-syscall helpers. Handles the `usize` return convention -// (errno encoded as negative values cast to usize) and keeps `std.c._errno` -// in sync so existing `cErrnoToWasi`-style callers keep working during the -// un-link-libc migration (W46). +// Thread-local errno set by pfd helpers. Read via `pfdErrno()`. Callers that +// previously consulted `std.c._errno().*` should use `pfdErrno()` instead so +// the code path does not depend on libc being linked. +pub threadlocal var pfd_last_errno: std.posix.E = .SUCCESS; + +pub fn pfdErrno() std.posix.E { + return pfd_last_errno; +} + +/// Copy libc's thread-local errno slot into `pfd_last_errno`. Call this after +/// a `std.c.*` call has returned a failure so that `pfdErrno()` reflects the +/// actual failure. Mac/BSD code paths use this; Linux pfd helpers set +/// `pfd_last_errno` directly from the syscall return value. +pub fn syncErrnoFromLibC() void { + switch (comptime builtin.os.tag) { + .linux, .windows => {}, + else => pfd_last_errno = @enumFromInt(std.c._errno().*), + } +} + +// Linux direct-syscall helpers. Syscalls return errno as negative values +// cast to `usize`; convert that into the POSIX-style (-1, errno-in-slot) +// convention that upstream callers already expect. fn linuxResultAsIsize(rc: usize) isize { const e = std.os.linux.errno(rc); if (e != .SUCCESS) { - // Mirror errno into libc's thread-local slot so that callers that - // read `std.c._errno()` see the failure. Direct Linux syscalls - // don't touch this slot on their own. - std.c._errno().* = @intFromEnum(e); + pfd_last_errno = e; return -1; } return @bitCast(rc); @@ -134,7 +150,7 @@ fn linuxResultAsIsize(rc: usize) isize { fn linuxResultAsI32(rc: usize) i32 { const e = std.os.linux.errno(rc); if (e != .SUCCESS) { - std.c._errno().* = @intFromEnum(e); + pfd_last_errno = e; return -1; } return 0; @@ -143,23 +159,45 @@ fn linuxResultAsI32(rc: usize) i32 { fn linuxResultAsI64(rc: usize) i64 { const e = std.os.linux.errno(rc); if (e != .SUCCESS) { - std.c._errno().* = @intFromEnum(e); + pfd_last_errno = e; return -1; } return @as(i64, @bitCast(@as(u64, rc))); } +// Mac/BSD helpers — call after a `std.c.*` invocation so `pfd_last_errno` +// reflects the failure. The `if` is comptime-inert on Linux/Windows because +// the whole `else =>` arm is comptime-pruned. +fn cResultAsIsize(rc: isize) isize { + if (rc < 0) pfd_last_errno = @enumFromInt(std.c._errno().*); + return rc; +} + +fn cResultAsI32(rc: c_int) i32 { + const r: i32 = @intCast(rc); + if (r != 0) pfd_last_errno = @enumFromInt(std.c._errno().*); + return r; +} + +fn cResultAsI64(rc: std.c.off_t) i64 { + if (rc < 0) pfd_last_errno = @enumFromInt(std.c._errno().*); + return @intCast(rc); +} + /// POSIX-style write. Returns bytes written (>= 0) or -1 on error. pub fn pfdWrite(handle: std.posix.fd_t, buf: []const u8) isize { switch (comptime builtin.os.tag) { .windows => { var written: windows.DWORD = 0; const ok = WriteFile(handle, buf.ptr, @intCast(buf.len), &written, null); - if (ok == windows.BOOL.FALSE) return -1; + if (ok == windows.BOOL.FALSE) { + pfd_last_errno = .IO; + return -1; + } return @intCast(written); }, .linux => return linuxResultAsIsize(std.os.linux.write(handle, buf.ptr, buf.len)), - else => return std.c.write(handle, buf.ptr, buf.len), + else => return cResultAsIsize(std.c.write(handle, buf.ptr, buf.len)), } } @@ -172,12 +210,13 @@ pub fn pfdRead(handle: std.posix.fd_t, buf: []u8) isize { if (ok == windows.BOOL.FALSE) { const err = GetLastError(); if (err == ERROR_BROKEN_PIPE or err == ERROR_HANDLE_EOF) return 0; + pfd_last_errno = .IO; return -1; } return @intCast(got); }, .linux => return linuxResultAsIsize(std.os.linux.read(handle, buf.ptr, buf.len)), - else => return std.c.read(handle, buf.ptr, buf.len), + else => return cResultAsIsize(std.c.read(handle, buf.ptr, buf.len)), } } @@ -194,12 +233,13 @@ pub fn pfdPread(handle: std.posix.fd_t, buf: []u8, offset: u64) isize { if (ok == windows.BOOL.FALSE) { const err = GetLastError(); if (err == ERROR_BROKEN_PIPE or err == ERROR_HANDLE_EOF) return 0; + pfd_last_errno = .IO; return -1; } return @intCast(got); }, .linux => return linuxResultAsIsize(std.os.linux.pread(handle, buf.ptr, buf.len, @intCast(offset))), - else => return std.c.pread(handle, buf.ptr, buf.len, @intCast(offset)), + else => return cResultAsIsize(std.c.pread(handle, buf.ptr, buf.len, @intCast(offset))), } } @@ -213,11 +253,14 @@ pub fn pfdPwrite(handle: std.posix.fd_t, buf: []const u8, offset: u64) isize { }; var written: windows.DWORD = 0; const ok = WriteFile(handle, buf.ptr, @intCast(buf.len), &written, &ov); - if (ok == windows.BOOL.FALSE) return -1; + if (ok == windows.BOOL.FALSE) { + pfd_last_errno = .IO; + return -1; + } return @intCast(written); }, .linux => return linuxResultAsIsize(std.os.linux.pwrite(handle, buf.ptr, buf.len, @intCast(offset))), - else => return std.c.pwrite(handle, buf.ptr, buf.len, @intCast(offset)), + else => return cResultAsIsize(std.c.pwrite(handle, buf.ptr, buf.len, @intCast(offset))), } } @@ -230,15 +273,21 @@ pub fn pfdSeek(handle: std.posix.fd_t, offset: i64, whence: c_int) i64 { std.posix.SEEK.SET => FILE_BEGIN, std.posix.SEEK.CUR => FILE_CURRENT, std.posix.SEEK.END => FILE_END, - else => return -1, + else => { + pfd_last_errno = .INVAL; + return -1; + }, }; var new_pos: windows.LARGE_INTEGER = 0; const ok = SetFilePointerEx(handle, offset, &new_pos, method); - if (ok == windows.BOOL.FALSE) return -1; + if (ok == windows.BOOL.FALSE) { + pfd_last_errno = .IO; + return -1; + } return new_pos; }, .linux => return linuxResultAsI64(std.os.linux.lseek(handle, offset, @intCast(whence))), - else => return std.c.lseek(handle, offset, whence), + else => return cResultAsI64(std.c.lseek(handle, offset, whence)), } } @@ -258,7 +307,7 @@ pub fn pfdMkdirAt(dirfd: std.posix.fd_t, path: [*:0]const u8, mode: u32) i32 { switch (comptime builtin.os.tag) { .windows => return -1, .linux => return linuxResultAsI32(std.os.linux.mkdirat(dirfd, path, mode)), - else => return @intCast(std.c.mkdirat(dirfd, path, @intCast(mode))), + else => return cResultAsI32(std.c.mkdirat(dirfd, path, @intCast(mode))), } } @@ -266,7 +315,7 @@ pub fn pfdUnlinkAt(dirfd: std.posix.fd_t, path: [*:0]const u8, flags: u32) i32 { switch (comptime builtin.os.tag) { .windows => return -1, .linux => return linuxResultAsI32(std.os.linux.unlinkat(dirfd, path, flags)), - else => return @intCast(std.c.unlinkat(dirfd, path, @intCast(flags))), + else => return cResultAsI32(std.c.unlinkat(dirfd, path, @intCast(flags))), } } @@ -279,7 +328,7 @@ pub fn pfdRenameAt( switch (comptime builtin.os.tag) { .windows => return -1, .linux => return linuxResultAsI32(std.os.linux.renameat(old_dirfd, old_path, new_dirfd, new_path)), - else => return @intCast(std.c.renameat(old_dirfd, old_path, new_dirfd, new_path)), + else => return cResultAsI32(std.c.renameat(old_dirfd, old_path, new_dirfd, new_path)), } } @@ -287,7 +336,7 @@ pub fn pfdReadlinkAt(dirfd: std.posix.fd_t, path: [*:0]const u8, buf: []u8) isiz switch (comptime builtin.os.tag) { .windows => return -1, .linux => return linuxResultAsIsize(std.os.linux.readlinkat(dirfd, path, buf.ptr, buf.len)), - else => return std.c.readlinkat(dirfd, path, buf.ptr, buf.len), + else => return cResultAsIsize(std.c.readlinkat(dirfd, path, buf.ptr, buf.len)), } } @@ -298,28 +347,26 @@ pub fn pfdDup(fd: std.posix.fd_t) i32 { const rc = std.os.linux.dup(fd); const e = std.os.linux.errno(rc); if (e != .SUCCESS) { - std.c._errno().* = @intFromEnum(e); + pfd_last_errno = e; return -1; } return @intCast(rc); }, - else => return @intCast(std.c.dup(fd)), + else => return cResultAsI32(std.c.dup(fd)), } } pub fn pfdFsync(handle: std.posix.fd_t) i32 { switch (comptime builtin.os.tag) { - .windows => return if (FlushFileBuffers(handle) == windows.BOOL.FALSE) -1 else 0, - .linux => { - const rc = std.os.linux.fsync(handle); - const e = std.os.linux.errno(rc); - if (e != .SUCCESS) { - std.c._errno().* = @intFromEnum(e); + .windows => { + if (FlushFileBuffers(handle) == windows.BOOL.FALSE) { + pfd_last_errno = .IO; return -1; } return 0; }, - else => return std.c.fsync(handle), + .linux => return linuxResultAsI32(std.os.linux.fsync(handle)), + else => return cResultAsI32(std.c.fsync(handle)), } } diff --git a/src/wasi.zig b/src/wasi.zig index 09cb24d5..d060c7a2 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -520,9 +520,13 @@ pub fn fdSize(fd: posix.fd_t) ?u64 { return @intCast(end); } -/// Read libc errno and map to a WASI Errno. +/// Map the most recent platform errno (set by `platform.pfd*` helpers or +/// by explicit `platform.syncErrnoFromLibC()` calls after a raw `std.c.*` +/// invocation) to a WASI `Errno`. Replaces the pre-W46 variant that read +/// `std.c._errno().*` directly — keeping it libc-free is what lets Linux +/// builds drop `link_libc = true`. fn cErrnoToWasi() Errno { - const e: std.posix.E = @enumFromInt(std.c._errno().*); + const e = platform.pfdErrno(); return switch (e) { .ACCES => .ACCES, .AGAIN => .AGAIN, @@ -1568,7 +1572,21 @@ pub fn fd_datasync(ctx: *anyopaque, _: usize) anyerror!void { return; } } else { - if (std.c.fdatasync(host_fd) != 0) { + const failed = switch (comptime builtin.os.tag) { + .linux => blk: { + const rc = std.os.linux.fdatasync(host_fd); + const e = std.os.linux.errno(rc); + if (e != .SUCCESS) platform.pfd_last_errno = e; + break :blk e != .SUCCESS; + }, + .windows => unreachable, + else => blk: { + const rc = std.c.fdatasync(host_fd); + if (rc != 0) platform.syncErrnoFromLibC(); + break :blk rc != 0; + }, + }; + if (failed) { try pushErrno(vm, cErrnoToWasi()); return; } @@ -1597,16 +1615,9 @@ pub fn fd_sync(ctx: *anyopaque, _: usize) anyerror!void { }; if (wasi.getHostFd(fd)) |host_fd| { - if (builtin.os.tag == .windows) { - if (platform.FlushFileBuffers(host_fd) == windows.BOOL.FALSE) { - try pushErrno(vm, .IO); - return; - } - } else { - if (std.c.fsync(host_fd) != 0) { - try pushErrno(vm, cErrnoToWasi()); - return; - } + if (platform.pfdFsync(host_fd) != 0) { + try pushErrno(vm, cErrnoToWasi()); + return; } try pushErrno(vm, .SUCCESS); } else { @@ -1946,19 +1957,21 @@ pub fn fd_fdstat_set_flags(ctx: *anyopaque, _: usize) anyerror!void { if (fdflags & 0x04 != 0) os_flags |= @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); if (fdflags & 0x10 != 0) os_flags |= @as(u32, @bitCast(posix.O{ .SYNC = true })); - if (comptime builtin.os.tag == .linux) { - const linux = std.os.linux; - const rc = linux.fcntl(host_fd, linux.F.SETFL, @as(usize, os_flags)); - if (posix.errno(rc) != .SUCCESS) { - try pushErrno(vm, .IO); - return; - } - } else { - const rc = std.c.fcntl(host_fd, std.c.F.SETFL, os_flags); - if (rc < 0) { - try pushErrno(vm, .IO); - return; - } + const failed = switch (comptime builtin.os.tag) { + .linux => blk: { + const linux = std.os.linux; + const rc = linux.fcntl(host_fd, linux.F.SETFL, @as(usize, os_flags)); + break :blk posix.errno(rc) != .SUCCESS; + }, + .windows => false, // fcntl not meaningful on Windows; treat as success + else => blk: { + const rc = std.c.fcntl(host_fd, std.c.F.SETFL, os_flags); + break :blk rc < 0; + }, + }; + if (failed) { + try pushErrno(vm, .IO); + return; } try pushErrno(vm, .SUCCESS); } @@ -1999,7 +2012,21 @@ pub fn fd_filestat_set_size(ctx: *anyopaque, _: usize) anyerror!void { }; if (wasi.getHostFd(fd)) |host_fd| { - if (std.c.ftruncate(host_fd, @bitCast(size)) != 0) { + const failed = switch (comptime builtin.os.tag) { + .linux => blk: { + const rc = std.os.linux.ftruncate(host_fd, @bitCast(size)); + const e = std.os.linux.errno(rc); + if (e != .SUCCESS) platform.pfd_last_errno = e; + break :blk e != .SUCCESS; + }, + .windows => unreachable, + else => blk: { + const rc = std.c.ftruncate(host_fd, @bitCast(size)); + if (rc != 0) platform.syncErrnoFromLibC(); + break :blk rc != 0; + }, + }; + if (failed) { try pushErrno(vm, cErrnoToWasi()); return; } @@ -2058,10 +2085,14 @@ pub fn fd_filestat_set_times(ctx: *anyopaque, _: usize) anyerror!void { // utimensat(fd, NULL, times, 0) == futimens(fd, times) const rc = std.os.linux.utimensat(host_fd, null, ×, 0); const e = std.os.linux.errno(rc); - if (e != .SUCCESS) std.c._errno().* = @intFromEnum(e); + if (e != .SUCCESS) platform.pfd_last_errno = e; break :blk e != .SUCCESS; }, - else => std.c.futimens(host_fd, ×) != 0, + else => blk: { + const rc = std.c.futimens(host_fd, ×); + if (rc != 0) platform.syncErrnoFromLibC(); + break :blk rc != 0; + }, }; if (failed) { try pushErrno(vm, cErrnoToWasi()); @@ -2550,7 +2581,21 @@ pub fn path_symlink(ctx: *anyopaque, _: usize) anyerror!void { try pushErrno(vm, .NAMETOOLONG); return; }; - if (std.c.symlinkat(old_z.ptr, host_fd, new_z.ptr) != 0) { + const failed = switch (comptime builtin.os.tag) { + .linux => blk: { + const rc = std.os.linux.symlinkat(old_z.ptr, host_fd, new_z.ptr); + const e = std.os.linux.errno(rc); + if (e != .SUCCESS) platform.pfd_last_errno = e; + break :blk e != .SUCCESS; + }, + .windows => unreachable, + else => blk: { + const rc = std.c.symlinkat(old_z.ptr, host_fd, new_z.ptr); + if (rc != 0) platform.syncErrnoFromLibC(); + break :blk rc != 0; + }, + }; + if (failed) { try pushErrno(vm, cErrnoToWasi()); return; } @@ -2606,7 +2651,21 @@ pub fn path_link(ctx: *anyopaque, _: usize) anyerror!void { try pushErrno(vm, .NAMETOOLONG); return; }; - if (std.c.linkat(old_host_fd, old_z.ptr, new_host_fd, new_z.ptr, 0) != 0) { + const failed = switch (comptime builtin.os.tag) { + .linux => blk: { + const rc = std.os.linux.linkat(old_host_fd, old_z.ptr, new_host_fd, new_z.ptr, 0); + const e = std.os.linux.errno(rc); + if (e != .SUCCESS) platform.pfd_last_errno = e; + break :blk e != .SUCCESS; + }, + .windows => unreachable, + else => blk: { + const rc = std.c.linkat(old_host_fd, old_z.ptr, new_host_fd, new_z.ptr, 0); + if (rc != 0) platform.syncErrnoFromLibC(); + break :blk rc != 0; + }, + }; + if (failed) { try pushErrno(vm, cErrnoToWasi()); return; } From 4b03c12429d04eec53340434b3d4e38f3e9b9246 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 01:50:40 +0900 Subject: [PATCH 2/6] refactor(delib-1d): trace.zig stderr write via platform.pfdWrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth step of the W46 un-link-libc migration. `stderrPrint` was still calling `std.c.write(stderr_fd, msg.ptr, msg.len)` directly — route it through `platform.pfdWrite` so Linux uses a direct syscall, macOS/BSD uses the std.c.* path, and Windows goes through kernel32 WriteFile (which is correct since `stderr_fd` on Windows is already `windows.peb().ProcessParameters.hStdError` — a real HANDLE). Before: `std.c.write(HANDLE, ...)` on Windows would have mis-routed to an arbitrary MSVCRT fd (same class of bug as the WASI fd_write regression fixed in 1af72d1). That path was never exercised at runtime because trace output is disabled by default, but the refactor closes the latent bug too. --- src/trace.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trace.zig b/src/trace.zig index 7879a5ac..cc9d209a 100644 --- a/src/trace.zig +++ b/src/trace.zig @@ -68,7 +68,7 @@ fn stderrPrint(comptime fmt: []const u8, args: anytype) void { std.os.windows.peb().ProcessParameters.hStdError else std.posix.STDERR_FILENO; - _ = std.c.write(stderr_fd, msg.ptr, msg.len); + _ = platform.pfdWrite(stderr_fd, msg); } pub fn traceJitCompile(tc: *const TraceConfig, func_idx: u32, ir_count: u32, code_size: u32) void { From 8072550028ff3f464e7b0dfa2e88b928ce90fc0a Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 01:57:09 +0900 Subject: [PATCH 3/6] refactor(delib-1e): platform.appCacheDir reads std.process.Environ.Map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last std.c.* dependency outside of Mac-only code paths: `platform.zig` was calling `std.c.getenv("HOME")` / `std.c.getenv("APPDATA")` etc. directly, which requires `link_libc = true` on Linux. Route env lookups through a process-wide `std.process.Environ.Map` captured at program start. `cli.main` calls `platform.setEnvironMap (init.environ_map)` once; `platform.envPath` reads from the captured pointer (returning null when unset — safe for tests that never exercise the cache/temp paths). Also convert the two remaining `std.c.{close,dup}` sites in `wasi.zig` (`HostHandle.close` and `HostHandle.duplicate`) to the existing `platform.pfd{Close,Dup}` helpers, which dispatch to Win32 / Linux syscalls / std.c as appropriate. Verified: - `zig build test` (Mac aarch64) — all pass. - `zig build -Dtarget=x86_64-linux-gnu` — clean. - `zig build -Dtarget=x86_64-windows-gnu` — clean. - `bash test/realworld/run_compat.sh` (Mac ReleaseSafe) — 50 / 0 / 0. The `link_libc = true` flip happens in a follow-up commit (Phase 1f) so that this change stays mechanically minimal — if something breaks, it's easier to bisect. --- src/cli.zig | 7 +++++++ src/platform.zig | 25 +++++++++++++++++-------- src/wasi.zig | 14 +++----------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/cli.zig b/src/cli.zig index ae8e55df..2a690114 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -23,6 +23,7 @@ const component_mod = @import("component.zig"); const guard_mod = @import("guard.zig"); const jit_mod = vm_mod.jit_mod; const cache_mod = @import("cache.zig"); +const platform = @import("platform.zig"); /// Process-wide Io handle. Populated once at the top of `main` from the /// `std.process.Init` the compiler-generated entrypoint hands us, and read @@ -40,6 +41,12 @@ pub fn main(init: std.process.Init) !void { cli_io = init.io; + // Expose the process environment to `platform` so subsequent callers + // (`appCacheDir`, `tempDirPath`) can look up HOME / TMPDIR / … without + // going through `std.c.getenv` — a prerequisite for dropping + // `link_libc = true` on Linux (W46 Phase 1e). + platform.setEnvironMap(init.environ_map); + // `init.gpa` is a DebugAllocator-backed allocator in Debug builds // (leak-checked by start.zig) and the appropriate production allocator // otherwise. `init.arena` is the process arena — args/environ live there. diff --git a/src/platform.zig b/src/platform.zig index e13b212a..798f8908 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -480,6 +480,19 @@ pub fn flushInstructionCache(ptr: [*]const u8, len: usize) void { } } +// Process-wide environment table captured at program start via `setEnvironMap`. +// This lets `envPath` / `appCacheDir` / `tempDirPath` look up variables without +// calling libc's `getenv` — the last remaining blocker for dropping +// `link_libc = true` on Linux (W46 Phase 1e). +var env_map_ref: ?*const std.process.Environ.Map = null; + +/// Capture the process's environment block. Call once at program start +/// (from `main(init: std.process.Init)`). Tests that never exercise +/// `envPath` may skip calling this; `envPath` returns null when unset. +pub fn setEnvironMap(m: *const std.process.Environ.Map) void { + env_map_ref = m; +} + pub fn appCacheDir(alloc: std.mem.Allocator, app_name: []const u8) ![]u8 { if (builtin.os.tag == .windows) { // Zig 0.16 removed `std.fs.getAppDataDir`. Build the path ourselves @@ -490,8 +503,8 @@ pub fn appCacheDir(alloc: std.mem.Allocator, app_name: []const u8) ![]u8 { return std.fmt.allocPrint(alloc, "{s}\\{s}", .{ base, app_name }); } - const home_ptr = std.c.getenv("HOME") orelse return error.NoCacheDir; - const home = std.mem.span(home_ptr); + const home = (try envPath(alloc, "HOME")) orelse return error.NoCacheDir; + defer alloc.free(home); return std.fmt.allocPrint(alloc, "{s}/.cache/{s}", .{ home, app_name }); } @@ -507,12 +520,8 @@ pub fn tempDirPath(alloc: std.mem.Allocator) ![]u8 { } fn envPath(alloc: std.mem.Allocator, name: []const u8) !?[]u8 { - var name_buf: [256]u8 = undefined; - if (name.len >= name_buf.len) return error.OutOfMemory; - @memcpy(name_buf[0..name.len], name); - name_buf[name.len] = 0; - const val_ptr = std.c.getenv(@ptrCast(&name_buf)) orelse return null; - const val = std.mem.span(val_ptr); + const m = env_map_ref orelse return null; + const val = m.get(name) orelse return null; if (val.len == 0) return null; return try alloc.dupe(u8, val); } diff --git a/src/wasi.zig b/src/wasi.zig index d060c7a2..36437db7 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -157,11 +157,7 @@ const HostHandle = struct { } fn close(self: HostHandle) void { - if (builtin.os.tag == .windows) { - _ = windows.CloseHandle(self.raw); - } else { - _ = std.c.close(self.raw); - } + platform.pfdClose(self.raw); } fn stat(self: HostHandle, io: std.Io) !std.Io.File.Stat { @@ -186,7 +182,7 @@ const HostHandle = struct { } break :blk dup_handle; } else blk: { - const rc = std.c.dup(self.raw); + const rc = platform.pfdDup(self.raw); if (rc < 0) return error.Unexpected; break :blk rc; }; @@ -288,11 +284,7 @@ pub const WasiContext = struct { } fn closeHandle(handle: std.Io.File.Handle) void { - if (builtin.os.tag == .windows) { - _ = windows.CloseHandle(handle); - } else { - _ = std.c.close(handle); - } + platform.pfdClose(handle); } pub fn deinit(self: *WasiContext) void { From d9a81119dcd4f524bb9ce71019b4ee738a63c705 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 01:57:50 +0900 Subject: [PATCH 4/6] refactor(delib-1f): flip `.link_libc = false` across all build.zig modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W46 Phase 1f — the payoff of the earlier phases. All `std.c.*` references in zwasm are now either: - routed through `platform.pfd*` helpers (which dispatch per OS), or - inside `else` branches of `switch (comptime builtin.os.tag)` blocks (analyzed only on Mac/BSD where libSystem auto-links), or - guarded by `if (builtin.os.tag == .linux) { … } else { … }` pairs that Zig prunes at comptime. So `link_libc` can safely drop to false for every module in build.zig (lib / cli / tests / examples / shared lib / static lib / FFI / C API). Mac still auto-links libSystem for the `extern "c"` decls on its own; Linux now builds strictly against `std.os.linux.*` syscalls; Windows uses kernel32 externs declared in `platform.zig`. Binary size impact (Linux ELF stripped, ReleaseSafe, expected at CI): - pre-flip: ~1.65 MB (above the 1.50 MB legacy guard, 1.80 MB current) - post-flip: expected ~1.38 MB (matching Mac) CI size guard and related docs will be dialed back from 1.80 MB to 1.50 MB in a follow-up commit once Linux numbers are confirmed green. Verified locally: - `zig build test` (Mac aarch64) — all pass. - `zig build -Dtarget=x86_64-linux-gnu` — clean. - `zig build -Dtarget=x86_64-windows-gnu` — clean. - `bash test/realworld/run_compat.sh` (Mac ReleaseSafe) — 50 / 0 / 0. --- build.zig | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/build.zig b/build.zig index bc44f24a..3dd58a47 100644 --- a/build.zig +++ b/build.zig @@ -28,16 +28,18 @@ pub fn build(b: *std.Build) void { options.addOption([]const u8, "version", build_zon.version); // Library module (for use as dependency and test root). - // link_libc is required because wasi.zig / cache.zig / platform.zig use - // `std.c.*` for POSIX operations that std.posix lost in Zig 0.16 - // (fsync, mkdirat, unlinkat, renameat, dup, pread/pwrite, futimens, …). - // On Linux the build is strict about this; macOS happens to auto-link - // libc for `extern "c"` decls but both platforms need it. + // link_libc = false post-W46 migration. WASI fd I/O and path-based ops + // now go through `platform.pfd*` helpers (Linux syscalls / Mac libSystem + // auto-link / Win32 kernel32). Env vars come from `std.process.Environ.Map` + // captured in `cli.main` (W46 Phase 1e). The `std.c.*` references that + // survive are all inside `else` branches of `switch (comptime builtin.os.tag)` + // blocks, so they are comptime-pruned on Linux/Windows and still resolve + // to libSystem on Mac. const mod = b.addModule("zwasm", .{ .root_source_file = b.path("src/types.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); mod.addOptions("build_options", options); @@ -55,7 +57,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/cli.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); cli_mod.addOptions("build_options", options); const cli = b.addExecutable(.{ @@ -82,7 +84,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path(ex.src), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); ex_mod.addImport("zwasm", mod); const ex_exe = b.addExecutable(.{ @@ -99,7 +101,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("test/e2e/e2e_runner.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); e2e_mod.addImport("zwasm", mod); const e2e = b.addExecutable(.{ @@ -116,7 +118,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("bench/fib_bench.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); bench_mod.addImport("zwasm", mod); const bench = b.addExecutable(.{ @@ -135,7 +137,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/fuzz_loader.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); fuzz_mod.addImport("zwasm", mod); const fuzz = b.addExecutable(.{ @@ -148,7 +150,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/fuzz_wat_loader.zig"), .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); fuzz_wat_mod.addImport("zwasm", mod); const fuzz_wat = b.addExecutable(.{ @@ -167,7 +169,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/c_api.zig"), .target = target, .optimize = if (lib_optimize) optimize else if (optimize == .Debug) .ReleaseSafe else optimize, - .link_libc = true, + .link_libc = false, }); lib_shared_mod.addOptions("build_options", options); const lib_shared = b.addLibrary(.{ @@ -182,7 +184,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/c_api.zig"), .target = target, .optimize = if (lib_optimize) optimize else if (optimize == .Debug) .ReleaseSafe else optimize, - .link_libc = true, + .link_libc = false, .pic = if (enable_pic) true else null, }); lib_static_mod.addOptions("build_options", options); @@ -218,7 +220,7 @@ pub fn build(b: *std.Build) void { .root_source_file = null, .target = target, .optimize = optimize, - .link_libc = true, + .link_libc = false, }); ct_mod.addCSourceFile(.{ .file = b.path(ct.src) }); ct_mod.addIncludePath(b.path("include")); From c11a94761619c771016073d42cf582038c3e056c Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 02:48:51 +0900 Subject: [PATCH 5/6] fix(delib-1c): gate test-site std.c.{pipe,dup,dup2,read,nanosleep} for Linux PR #49 Phase 1f flipped link_libc=false but three WASI unit tests and the VM cancellation test still reached for std.c.* POSIX helpers that require libc on Linux. Add pfdDup2 / pfdPipe / pfdSleepNs to platform.zig and route the tests through them so Ubuntu CI compiles without libc. Mac uses std.c.* via libSystem auto-link (unchanged); Linux uses std.os.linux direct syscalls; Windows returns -1 / uses kernel32.Sleep (tests already skip Windows early). --- src/platform.zig | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/vm.zig | 12 +----------- src/wasi.zig | 28 ++++++++++++++-------------- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/platform.zig b/src/platform.zig index 798f8908..24475662 100644 --- a/src/platform.zig +++ b/src/platform.zig @@ -370,6 +370,50 @@ pub fn pfdFsync(handle: std.posix.fd_t) i32 { } } +pub fn pfdDup2(oldfd: std.posix.fd_t, newfd: std.posix.fd_t) i32 { + switch (comptime builtin.os.tag) { + .windows => return -1, + .linux => return linuxResultAsI32(std.os.linux.dup2(oldfd, newfd)), + else => return cResultAsI32(std.c.dup2(oldfd, newfd)), + } +} + +pub fn pfdPipe(fds: *[2]std.posix.fd_t) i32 { + switch (comptime builtin.os.tag) { + .windows => return -1, + .linux => return linuxResultAsI32(std.os.linux.pipe(fds)), + else => return cResultAsI32(std.c.pipe(fds)), + } +} + +/// Sleep for the given number of nanoseconds. Best-effort — short-sleep +/// tests use this to give other threads time to start. +pub fn pfdSleepNs(ns: u64) void { + switch (comptime builtin.os.tag) { + .windows => { + const K32 = struct { + extern "kernel32" fn Sleep(dwMilliseconds: windows.DWORD) callconv(.winapi) void; + }; + const ms: windows.DWORD = @intCast(@max(ns / 1_000_000, 1)); + K32.Sleep(ms); + }, + .linux => { + const req: std.os.linux.timespec = .{ + .sec = @intCast(ns / 1_000_000_000), + .nsec = @intCast(ns % 1_000_000_000), + }; + _ = std.os.linux.nanosleep(&req, null); + }, + else => { + const req: std.posix.timespec = .{ + .sec = @intCast(ns / 1_000_000_000), + .nsec = @intCast(ns % 1_000_000_000), + }; + _ = std.c.nanosleep(&req, null); + }, + } +} + pub fn reservePages(size: usize, prot: Protection) PageError![]align(page_size) u8 { if (builtin.os.tag == .windows) { const addr = VirtualAlloc(null, size, .{ .RESERVE = true }, protectionToWin(prot)) orelse diff --git a/src/vm.zig b/src/vm.zig index 5a551a6d..1100ab0d 100644 --- a/src/vm.zig +++ b/src/vm.zig @@ -10237,18 +10237,8 @@ test "armJitFuel — cancellable = false prevents capping" { } // Small cross-platform ~1ms sleep used by the cancellation tests below. -// `std.posix.timespec` is `void` on Windows, so the nanosleep-based path -// cannot even be *constructed* on Windows — branch at comptime. fn sleepOneMillisecondForCancelTest() void { - if (builtin.os.tag == .windows) { - const K32 = struct { - extern "kernel32" fn Sleep(dwMilliseconds: u32) callconv(.winapi) void; - }; - K32.Sleep(1); - } else { - const req: std.posix.timespec = .{ .sec = 0, .nsec = 1 * std.time.ns_per_ms }; - _ = std.c.nanosleep(&req, null); - } + @import("platform.zig").pfdSleepNs(std.time.ns_per_ms); } test "Cancellation — cancel flag stops interpreter loop" { diff --git a/src/wasi.zig b/src/wasi.zig index 36437db7..91eb819c 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -2917,16 +2917,16 @@ test "WASI — fd_write via 07_wasi_hello.wasm" { // Create pipe for capturing stdout var pipe_fds: [2]posix.fd_t = undefined; - if (std.c.pipe(&pipe_fds) != 0) return error.SkipZigTest; + if (platform.pfdPipe(&pipe_fds) != 0) return error.SkipZigTest; const pipe = pipe_fds; - defer _ = std.c.close(pipe[0]); + defer platform.pfdClose(pipe[0]); // Redirect stdout to pipe write end - const saved_stdout = std.c.dup(@as(posix.fd_t, 1)); + const saved_stdout = platform.pfdDup(@as(posix.fd_t, 1)); if (saved_stdout < 0) return error.SkipZigTest; - defer _ = std.c.close(saved_stdout); - if (std.c.dup2(pipe[1], @as(posix.fd_t, 1)) < 0) return error.SkipZigTest; - _ = std.c.close(pipe[1]); + defer platform.pfdClose(saved_stdout); + if (platform.pfdDup2(pipe[1], @as(posix.fd_t, 1)) < 0) return error.SkipZigTest; + platform.pfdClose(pipe[1]); // Run _start var vm_inst = Vm.init(alloc); @@ -2937,11 +2937,11 @@ test "WASI — fd_write via 07_wasi_hello.wasm" { }; // Restore stdout - _ = std.c.dup2(saved_stdout, @as(posix.fd_t, 1)); + _ = platform.pfdDup2(saved_stdout, @as(posix.fd_t, 1)); // Read captured output var buf: [256]u8 = undefined; - const n_rc = std.c.read(pipe[0], &buf, buf.len); + const n_rc = platform.pfdRead(pipe[0], buf[0..]); if (n_rc < 0) return error.SkipZigTest; const output = buf[0..@intCast(n_rc)]; @@ -3478,9 +3478,9 @@ test "stdio override: custom fd replaces default" { // Create a pipe to use as custom stdout var pipe_fds: [2]std.posix.fd_t = undefined; - if (std.c.pipe(&pipe_fds) != 0) return error.SkipZigTest; + if (platform.pfdPipe(&pipe_fds) != 0) return error.SkipZigTest; const pipe = pipe_fds; - defer _ = std.c.close(pipe[0]); + defer platform.pfdClose(pipe[0]); // Set stdout (fd 1) to write end of pipe, with ownership (runtime closes it) ctx.setStdioFd(1, pipe[1], .own); @@ -3499,10 +3499,10 @@ test "stdio override: borrow mode does not close fd on deinit" { const alloc = testing.allocator; var pipe_fds: [2]std.posix.fd_t = undefined; - if (std.c.pipe(&pipe_fds) != 0) return error.SkipZigTest; + if (platform.pfdPipe(&pipe_fds) != 0) return error.SkipZigTest; const pipe = pipe_fds; - defer _ = std.c.close(pipe[0]); - defer _ = std.c.close(pipe[1]); + defer platform.pfdClose(pipe[0]); + defer platform.pfdClose(pipe[1]); { var ctx = WasiContext.init(alloc); @@ -3512,7 +3512,7 @@ test "stdio override: borrow mode does not close fd on deinit" { // pipe[1] should still be valid (borrowed, not closed by deinit) // Writing to it should succeed - const written_rc = std.c.write(pipe[1], "ok", 2); + const written_rc = platform.pfdWrite(pipe[1], "ok"); try testing.expect(written_rc == 2); } From 04ac19d6db3e5ed6f66fc9644a10a47817273f72 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sat, 25 Apr 2026 02:54:22 +0900 Subject: [PATCH 6/6] fix(delib-1f): keep link_libc=true for C API targets (shared-lib, static-lib, c-test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #49 Phase 1f flipped link_libc=false on every module, but the C API library modules (src/c_api.zig) use std.heap.c_allocator as their default backing allocator, which requires libc on every target: - malloc/realloc/free are extern "c" (Linux glibc/musl, Windows msvcrt) - Mac libSystem auto-link masked the issue locally Restoring link_libc=true on lib_shared_mod, lib_static_mod, and the c_tests ct_mod. The CLI, tests, examples, e2e, bench, and fuzz modules all remain link_libc=false — that is where the W46 size win comes from. C API consumers link libc anyway (any real C program does), so this costs them nothing. --- build.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/build.zig b/build.zig index 3dd58a47..643cc99f 100644 --- a/build.zig +++ b/build.zig @@ -164,12 +164,18 @@ pub fn build(b: *std.Build) void { // Default to ReleaseSafe: Zig 0.15's Debug-mode shared libraries // crash on Linux x86_64 due to GPA/PIC codegen issues (see #11). // Users embedding zwasm want optimized code anyway. + // + // C API targets keep link_libc = true: `src/c_api.zig` uses + // `std.heap.c_allocator` as the default backing allocator, which + // requires libc on every platform (Mac libSystem auto-linked, Linux + // glibc/musl, Windows msvcrt). Consumers of libzwasm are C programs + // that always link libc anyway, so this costs them nothing. const lib_optimize = b.option(bool, "lib-debug", "Build libraries in Debug mode (default: false)") orelse false; const lib_shared_mod = b.createModule(.{ .root_source_file = b.path("src/c_api.zig"), .target = target, .optimize = if (lib_optimize) optimize else if (optimize == .Debug) .ReleaseSafe else optimize, - .link_libc = false, + .link_libc = true, }); lib_shared_mod.addOptions("build_options", options); const lib_shared = b.addLibrary(.{ @@ -184,7 +190,7 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/c_api.zig"), .target = target, .optimize = if (lib_optimize) optimize else if (optimize == .Debug) .ReleaseSafe else optimize, - .link_libc = false, + .link_libc = true, .pic = if (enable_pic) true else null, }); lib_static_mod.addOptions("build_options", options); @@ -220,7 +226,7 @@ pub fn build(b: *std.Build) void { .root_source_file = null, .target = target, .optimize = optimize, - .link_libc = false, + .link_libc = true, }); ct_mod.addCSourceFile(.{ .file = b.path(ct.src) }); ct_mod.addIncludePath(b.path("include"));