From 94e35da4db953fef5b80c58d648ad54111e16647 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Sun, 20 Sep 2026 19:08:12 -0500 Subject: [PATCH 01/17] Make a Mac a safe host to build on: cap zig, and resolve the bundle Prerequisite for the arm64/macOS runtime port that follows, and useful on its own. A single zig process reached 77.5 GB on a 96 GB Mac and forced two physical resets (JetsamEvent-2026-09-20-170707: largestProcess "zig", lifetimeMax 77521 MB, its parent ruby only 100 MB -- one process, not the fork pool). Darwin does not enforce RLIMIT_AS, so the cap cannot be delegated to the kernel: `ulimit -v` is rejected outright and a test process allocated 10 GB straight through it. tools/mem-guard polls a command's process tree and kills it past a cap, descendants included so link subprocesses cannot outlive it; tools/zig-guard resolves the real zig and delegates. Every zig spawn in `clear` goes through the single ZIG constant, so routing that one value through the guard caps them all -- argv arrays and interpolated command strings alike -- rather than patching each call site. Default cap is a quarter of RAM; CLEAR_ZIG_MAX_RSS_MB tunes it and 0 disables. A kill is worded so it reads as a memory runaway, not a CLEAR compile error. Gemfile.lock gains arm64-darwin: the lock listed only x86_64-linux-gnu, so `bundle exec` could not resolve on a Mac at all and every Ruby tool died before it started. Co-Authored-By: Claude Opus 5 --- Gemfile.lock | 11 +++++++- clear | 15 +++++++++- tools/mem-guard | 73 +++++++++++++++++++++++++++++++++++++++++++++++++ tools/zig-guard | 25 +++++++++++++++++ 4 files changed, 122 insertions(+), 2 deletions(-) create mode 100755 tools/mem-guard create mode 100755 tools/zig-guard diff --git a/Gemfile.lock b/Gemfile.lock index 4b61b2fdf..8e4c36695 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -121,6 +121,9 @@ GEM path_expander (~> 2.0) prism (~> 1.7) sexp_processor (~> 4.8) + google-protobuf (4.35.1-arm64-darwin) + bigdecimal + rake (~> 13.3) google-protobuf (4.35.1-x86_64-linux-gnu) bigdecimal rake (~> 13.3) @@ -258,6 +261,7 @@ GEM simplecov (>= 0.22.0) tty-which (~> 0.5.0) virtus (~> 2.0) + rubydex (0.2.1-arm64-darwin) rubydex (0.2.1-x86_64-linux) sexp_processor (4.17.5) simplecov (0.22.0) @@ -272,6 +276,7 @@ GEM sorbet (0.6.13196) sorbet-static (= 0.6.13196) sorbet-runtime (0.6.13196) + sorbet-static (0.6.13196-universal-darwin) sorbet-static (0.6.13196-x86_64-linux) sorbet-static-and-runtime (0.6.13196) sorbet (= 0.6.13196) @@ -284,6 +289,7 @@ GEM rexml (>= 3.2.6) sorbet-static-and-runtime (>= 0.5.10187) thor (>= 0.19.2) + sqlite3 (2.9.5-arm64-darwin) sqlite3 (2.9.5-x86_64-linux-gnu) stackprof (0.2.28) stringio (3.2.0) @@ -301,6 +307,7 @@ GEM tsort thor (1.5.0) thread_safe (0.3.6) + tree_sitter (0.1.0-arm64-darwin) tree_sitter (0.1.0-x86_64-linux) tsort (0.2.0) tty-cursor (0.7.1) @@ -325,6 +332,8 @@ GEM zeitwerk (2.7.5) PLATFORMS + arm64-darwin + arm64-darwin-25 x86_64-linux-gnu DEPENDENCIES @@ -363,4 +372,4 @@ DEPENDENCIES tty-screen BUNDLED WITH - 2.7.2 + 4.0.9 diff --git a/clear b/clear index c447146a3..e36568a59 100755 --- a/clear +++ b/clear @@ -53,13 +53,26 @@ TRANSPILER = File.join(SRC_DIR, 'backends', 'transpiler.rb') TRANSPILER_CACHE_DIR = File.join(ZIG_DIR, '.clear-transpile-cache') # Find Zig compiler -ZIG = [ +REAL_ZIG = [ File.join(File.expand_path('~'), 'zig-x86_64-linux-0.16.0', 'zig'), File.join(ZIG_DIR, 'zig-new', 'zig'), File.join(ZIG_DIR, 'zig', 'zig'), `which zig 2>/dev/null`.strip ].find { |p| !p.empty? && File.exist?(p) } || 'zig' +# ...and cap its memory. A runaway zig reached 77 GB on a 96 GB machine and +# forced two physical resets; Darwin does not enforce RLIMIT_AS, so the cap has +# to be an external watchdog. Every zig spawn below goes through ZIG -- argv +# arrays and interpolated command strings alike -- so routing this one constant +# through the wrapper caps all of them. CLEAR_ZIG_MAX_RSS_MB tunes it. +ZIG_GUARD = File.join(CLEAR_ROOT, 'tools', 'zig-guard') +ZIG = if File.executable?(ZIG_GUARD) + ENV['CLEAR_REAL_ZIG'] = REAL_ZIG + ZIG_GUARD + else + REAL_ZIG + end + BUILD_SUPPORT_CONFIG = ClearBuildSupport::Config.new( src_dir: SRC_DIR, zig_dir: ZIG_DIR, diff --git a/tools/mem-guard b/tools/mem-guard new file mode 100755 index 000000000..7a99d599f --- /dev/null +++ b/tools/mem-guard @@ -0,0 +1,73 @@ +#!/bin/sh +# Run any command under a hard resident-memory cap. +# +# Darwin does not enforce RLIMIT_AS -- `ulimit -v` is rejected outright and a +# test process allocated 10 GB straight through it -- so a runaway child cannot +# be capped by the kernel. This polls the child's process tree and kills it. +# A single zig run reached 77 GB here and forced two physical resets; the Ruby +# closure census is the same hazard from the other direction. +# +# MEM_GUARD_CAP_MB=32768 tools/mem-guard ruby big_job.rb +# +# MEM_GUARD_CAP_MB defaults to a quarter of RAM; 0 disables the watchdog. +# MEM_GUARD_LABEL names the child in messages. MEM_GUARD_REPORT=1 prints peak. +set -u + +[ $# -ge 1 ] || { echo "mem-guard: usage: mem-guard [args...]" >&2; exit 2; } +LABEL="${MEM_GUARD_LABEL:-$(basename "$1")}" + +CAP_MB="${MEM_GUARD_CAP_MB:-}" +if [ -z "$CAP_MB" ]; then + total_mb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 8589934592) / 1048576 )) + CAP_MB=$(( total_mb / 4 )) + [ "$CAP_MB" -lt 4096 ] && CAP_MB=4096 +fi +[ "$CAP_MB" = "0" ] && exec "$@" + +"$@" & +child=$! +cap_kb=$(( CAP_MB * 1024 )) +peak_kb=0 + +# Resident set of the WHOLE tree: a child that spawns workers (zig's linker, +# a fork pool) would slip a cap that only watched the root. +tree_kb() { + ps -Ao pid=,ppid=,rss= 2>/dev/null | awk -v root="$1" ' + { pid[NR]=$1; ppid[NR]=$2; rss[NR]=$3; n=NR } + END { + want[root]=1 + for (pass=0; pass<12; pass++) + for (i=1; i<=n; i++) if (want[ppid[i]]) want[pid[i]]=1 + t=0; for (i=1; i<=n; i++) if (want[pid[i]]) t+=rss[i] + print t+0 + }' +} + +kill_tree() { + ps -Ao pid=,ppid= 2>/dev/null | awk -v root="$1" ' + { pid[NR]=$1; ppid[NR]=$2; n=NR } + END { + want[root]=1 + for (pass=0; pass<12; pass++) + for (i=1; i<=n; i++) if (want[ppid[i]]) want[pid[i]]=1 + for (i=1; i<=n; i++) if (want[pid[i]]) print pid[i] + }' | while read -r p; do kill -9 "$p" 2>/dev/null; done +} + +trap 'kill_tree "$child"; exit 130' INT TERM + +while kill -0 "$child" 2>/dev/null; do + rss_kb=$(tree_kb "$child"); [ -n "$rss_kb" ] || rss_kb=0 + [ "$rss_kb" -gt "$peak_kb" ] && peak_kb=$rss_kb + if [ "$rss_kb" -gt "$cap_kb" ]; then + kill_tree "$child" + echo "mem-guard: KILLED $LABEL at $(( rss_kb / 1024 )) MB resident (cap ${CAP_MB} MB)." >&2 + echo "mem-guard: this is a memory runaway, not a compile error." >&2 + wait "$child" 2>/dev/null + exit 137 + fi + sleep 0.25 +done +wait "$child"; status=$? +[ -n "${MEM_GUARD_REPORT:-}" ] && echo "mem-guard: $LABEL peak $(( peak_kb / 1024 )) MB" >&2 +exit $status diff --git a/tools/zig-guard b/tools/zig-guard new file mode 100755 index 000000000..382637484 --- /dev/null +++ b/tools/zig-guard @@ -0,0 +1,25 @@ +#!/bin/sh +# Run zig under a hard resident-memory cap (see tools/mem-guard for the why: +# a single zig process reached 77 GB here and forced two physical resets, and +# Darwin does not enforce RLIMIT_AS). +# +# Every zig spawn in ./clear goes through the single ZIG constant, so pointing +# that one value at this wrapper caps them all -- argv arrays and interpolated +# command strings alike. CLEAR_ZIG_MAX_RSS_MB tunes the cap; 0 disables it. +set -u + +REAL_ZIG="${CLEAR_REAL_ZIG:-}" +if [ -z "$REAL_ZIG" ] || [ ! -x "$REAL_ZIG" ]; then + # Invoked outside ./clear: take the first zig on PATH that is not this file. + self=$(cd "$(dirname "$0")" 2>/dev/null && pwd)/$(basename "$0") + for c in $(command -v -a zig 2>/dev/null); do + [ "$c" = "$self" ] && continue + REAL_ZIG="$c"; break + done +fi +[ -n "$REAL_ZIG" ] || { echo "zig-guard: no real zig found (set CLEAR_REAL_ZIG)" >&2; exit 127; } + +MEM_GUARD_CAP_MB="${CLEAR_ZIG_MAX_RSS_MB:-}" \ +MEM_GUARD_LABEL=zig \ +MEM_GUARD_REPORT="${CLEAR_ZIG_GUARD_REPORT:-}" \ + exec "$(dirname "$0")/mem-guard" "$REAL_ZIG" "$@" From 3d83756871a27b9d2054efa20ac1935ba29e95b2 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Sun, 20 Sep 2026 19:13:52 -0500 Subject: [PATCH 02/17] Make the runtime's assembly actually build for aarch64 switch.S and onRoot.S each carry an `#if defined(__aarch64__)` block, but neither had ever been assembled: on aarch64 `#` introduces an immediate rather than a comment, so every `# ...` comment in those blocks is a syntax error, and `.section .text` is ELF-only syntax that Mach-O rejects. The blocks were dead code that looked like arm64 support. Building CLEAR for aarch64-linux fails on master at `stp x29, x30, [sp, #-48]! # Allocate ...`. - Comments inside the aarch64 blocks, and in switch.S's shared data header, become `//`, which both assemblers accept. The x86-64 bodies are untouched. - `.section .text` becomes `.text` in the aarch64 blocks. - Globals that Zig links against go through a new SYM() macro, because Mach-O prefixes C symbols with an underscore and ELF does not. - The ~500 lines of unguarded x86-64 in switch.S -- the SNAPSHOT/CRUMB macros, abi_panic, __morestack, __lessstack and the stack-stitch test harness -- are guarded so a non-x86 target no longer tries to assemble them. Only switch_context_asm and call_on_stack_asm are needed to build a CLEAR program; everything else in there is referenced solely by the x86 asm tests. - The `.weak` group is ELF-guarded: `.weak` is an ELF directive, and test_parent_ctx is declared weak but never defined (the label below it reads `test_paretn_ctx`), which only resolves because ELF lets a weak undefined symbol be 0. The shipping target is provably unchanged: assembled for x86_64-linux before and after, the disassembled text is identical (399 and 28 instruction lines) and the symbol tables are identical. The objects differ only in the source path embedded in debug info. aarch64-linux now builds a complete CLEAR executable (5.8 MB ELF, 676 MB peak). arm64-macos assembles both files but does not link yet: runtime/scheduler.zig is built on io_uring and eventfd, so std.os.linux.IoUring fails to compile on Darwin. Full macOS support needs a kqueue backend behind the existing RingType seam -- about twelve ring operations -- which is a separate subsystem port. Co-Authored-By: Claude Opus 5 --- zig/runtime/onRoot.S | 43 ++++++++++++++++++++++++++----------------- zig/runtime/switch.S | 33 ++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/zig/runtime/onRoot.S b/zig/runtime/onRoot.S index aaef2f30d..eeb8ed8dc 100644 --- a/zig/runtime/onRoot.S +++ b/zig/runtime/onRoot.S @@ -1,3 +1,12 @@ +// Mach-O prefixes C symbols with an underscore and ELF does not, so the +// global below goes through SYM() and one spelling serves both. Plain `.text` +// is used rather than `.section .text`, which Mach-O's assembler rejects. +#if defined(__APPLE__) +# define SYM(name) _##name +#else +# define SYM(name) name +#endif + #if defined(__x86_64__) .section .text @@ -50,36 +59,36 @@ call_on_stack_asm: #if defined(__aarch64__) -.section .text -.global call_on_stack_asm +.text +.global SYM(call_on_stack_asm) .align 2 -# x0 = new_stack_ptr, x1 = func_ptr, x2 = arg_ptr -call_on_stack_asm: - # 1. Push FP, LR and the current SP to the Fiber stack +// x0 = new_stack_ptr, x1 = func_ptr, x2 = arg_ptr +SYM(call_on_stack_asm): + // 1. Push FP, LR and the current SP to the Fiber stack mov x9, sp - stp x29, x30, [sp, #-48]! # Allocate 48 bytes (16-byte aligned) + stp x29, x30, [sp, #-48]! // Allocate 48 bytes (16-byte aligned) mov x29, sp - str x9, [sp, #16] # Save Fiber SP + str x9, [sp, #16] // Save Fiber SP - # 2. Save Floating Point status + // 2. Save Floating Point status mrs x10, fpcr str x10, [sp, #24] - # 3. Switch to G0 - and x0, x0, #~0xf # Ensure 16-byte align + // 3. Switch to G0 + and x0, x0, #~0xf // Ensure 16-byte align mov sp, x0 - # 4. Execute - mov x0, x2 # arg_ptr -> x0 + // 4. Execute + mov x0, x2 // arg_ptr -> x0 blr x1 - # 5. Restore - mov sp, x29 # Switch back to Fiber stack frame - ldr x10, [sp, #24] # Restore FP Control + // 5. Restore + mov sp, x29 // Switch back to Fiber stack frame + ldr x10, [sp, #24] // Restore FP Control msr fpcr, x10 - ldr x9, [sp, #16] # Load Fiber SP - ldp x29, x30, [sp], #48 # Pop and Clean up + ldr x9, [sp, #16] // Load Fiber SP + ldp x29, x30, [sp], #48 // Pop and Clean up mov sp, x9 ret diff --git a/zig/runtime/switch.S b/zig/runtime/switch.S index 58e3fe1f8..8932cea49 100644 --- a/zig/runtime/switch.S +++ b/zig/runtime/switch.S @@ -1,4 +1,14 @@ -# switch.s +// switch.s + +// Mach-O prefixes C symbols with an underscore and ELF does not, so every +// global that Zig links against goes through SYM() and one spelling serves +// both. Plain `.text` is used rather than `.section .text`, which Mach-O's +// assembler rejects. +#if defined(__APPLE__) +# define SYM(name) _##name +#else +# define SYM(name) name +#endif .data .global breadcrumb_counter @@ -6,7 +16,7 @@ .global abi_error_code abi_error_code: .quad 0 breadcrumb_counter: .quad 0 -breadcrumbs: .space 512 # 64 breadcrumbs +breadcrumbs: .space 512 // 64 breadcrumbs .data .balign 8 @@ -23,7 +33,7 @@ test_saved_rsp: .quad 0 .global entry_r15 .global fake_stack -fake_stack: .space 2048 # 2KB fake stack +fake_stack: .space 2048 // 2KB fake stack entry_rsp: .quad 0 entry_rbx: .quad 0 entry_rbp: .quad 0 @@ -32,6 +42,7 @@ entry_r13: .quad 0 entry_r14: .quad 0 entry_r15: .quad 0 +#if defined(__ELF__) .weak test_stack_limit .weak test_parent_ctx .weak capture_pivot @@ -40,7 +51,10 @@ test_stack_limit: .quad 0 test_paretn_ctx: .space 0 capture_pivot: .space 0 capture_restore: .space 0 +#endif + +#if defined(__x86_64__) .text .cfi_sections .eh_frame @@ -198,6 +212,8 @@ abi_panic: +#endif // __x86_64__ (macros, breadcrumbs, abi_panic) + # Check for x86_64 (Linux/Intel) #if defined(__x86_64__) @@ -232,12 +248,12 @@ switch_context_asm: # Check for ARM64 (Apple Silicon/Linux ARM) #if defined(__aarch64__) -.section .text -.global switch_context_asm +.text +.global SYM(switch_context_asm) .align 2 -switch_context_asm: - # x0 = from, x1 = to +SYM(switch_context_asm): + // x0 = from, x1 = to mov x9, sp str x9, [x0, #0x00] stp x19, x20, [x0, #0x10] @@ -258,6 +274,8 @@ switch_context_asm: #endif +#if defined(__x86_64__) + .weak __zig_stack_overflow_handler __zig_stack_overflow_handler: ret # no-op @@ -603,3 +621,4 @@ run_stack_stitch_harness: ret .cfi_endproc +#endif // __x86_64__ (morestack/lessstack and the asm test harness) From 77fb2b01d1b70d14c72bf23f6c8950c6ebb9cf67 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Sun, 20 Sep 2026 19:22:18 -0500 Subject: [PATCH 03/17] Run CLEAR on macOS: portable completion ring, and a correct aarch64 fiber switch CLEAR now builds and runs natively on arm64 macOS. Three defects stood between here and there, and the last two were live bugs in the aarch64 path that no target had ever exercised. 1. The scheduler was built directly on io_uring, so std.os.linux.IoUring failed to compile on Darwin. Everything Linux-specific it touched -- IoUring, io_uring_cqe, kernel_timespec, POLL.IN, IORING_POLL_ADD_MULTI and eventfd -- is now named once in runtime/io-backend.zig and selected at comptime. On Linux every alias IS the std type, so Linux codegen is unchanged; elsewhere a poll()-based PollRing parks on a wake pipe (Darwin has no eventfd) and reports AsyncIoUnsupported for socket submissions. It is deliberately not an async backend: it supports what a compute-only program needs. A kqueue backend would drop in behind this same interface without touching the scheduler. 2. The aarch64 switch_context_asm wrote the wrong offsets. fiber-core.zig's Context packs x19 at 0x08, but the assembly stored it at 0x10 -- every register was off by eight, and fp/lr at 0x58/0x60 were never touched at all. 3. Worse, x29/x30 were neither saved nor restored, and a fiber's entry point was only written to the stack. That is the x86 model, where `ret` pops the return address; aarch64's `ret` branches to the link register. So the first switch into a fiber returned to whatever x30 happened to hold -- in a Debug build, the 0xCC stack fill, which is exactly the `Segmentation fault at address 0xcccccccccccccccc` this produced. The assembly now saves and restores x29/x30, and a fiber seeds .lr with its entry point on aarch64. Verified: hello world builds and prints on arm64-macos; x86_64-linux and aarch64-linux both still link complete executables; and switch.S assembled for x86_64-linux is still instruction-for-instruction identical to master. Known limitation: on macOS the build needs the LLVM backend (--safe or --optimized). The default self-hosted backend grows without bound there -- past 31 GB on hello world -- which is the runaway that originally took this machine down twice and is why tools/mem-guard exists. Co-Authored-By: Claude Opus 5 --- zig/runtime/fiber-core.zig | 9 +- zig/runtime/io-backend.zig | 178 +++++++++++++++++++++++++++++++++++++ zig/runtime/scheduler.zig | 36 ++++---- zig/runtime/switch.S | 31 ++++--- 4 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 zig/runtime/io-backend.zig diff --git a/zig/runtime/fiber-core.zig b/zig/runtime/fiber-core.zig index 6405b436d..f68bfb1ee 100644 --- a/zig/runtime/fiber-core.zig +++ b/zig/runtime/fiber-core.zig @@ -254,7 +254,14 @@ pub const Fiber = struct { .stack = stack, // Point SP to the address we just wrote. // When 'ret' runs, it pops the value AT this pointer. - .ctx = Context{ .sp = initial_sp }, + // x86 starts a fiber by `ret`-ing off the stack slot written + // above; aarch64's `ret` branches to the link register instead, so + // the entry point has to be seeded there or the first switch + // returns into the 0xCC stack fill. + .ctx = if (builtin.cpu.arch == .aarch64) + Context{ .sp = initial_sp, .lr = entry_fn } + else + Context{ .sp = initial_sp }, .stack_limit = limit, .parent_ctx = undefined, .size_class = size, diff --git a/zig/runtime/io-backend.zig b/zig/runtime/io-backend.zig new file mode 100644 index 000000000..3f8942e53 --- /dev/null +++ b/zig/runtime/io-backend.zig @@ -0,0 +1,178 @@ +// Platform selection for the scheduler's completion ring. +// +// The scheduler is built on io_uring, which exists only on Linux. Everything +// Linux-specific it touches is named here once, so the scheduler itself has no +// `std.os.linux` in it and a non-Linux target gets a working substitute rather +// than a compile error. On Linux every alias below IS the std type, chosen at +// comptime, so Linux codegen is unchanged. +// +// The substitute is deliberately NOT an async I/O backend. It supports exactly +// what a compute-only CLEAR program needs -- park until woken or until a +// timeout expires -- and reports AsyncIoUnsupported for socket and file +// submissions. A real macOS port would put a kqueue backend here, behind this +// same interface; nothing outside this file would change. +const std = @import("std"); +const builtin = @import("builtin"); +const compat = @import("../lib/compat.zig"); + +pub const have_io_uring = builtin.os.tag == .linux; + +pub const Cqe = if (have_io_uring) std.os.linux.io_uring_cqe else extern struct { + user_data: u64 = 0, + res: i32 = 0, + flags: u32 = 0, +}; + +pub const KernelTimespec = if (have_io_uring) std.os.linux.kernel_timespec else extern struct { + sec: i64 = 0, + nsec: i64 = 0, +}; + +pub const Sqe = if (have_io_uring) std.os.linux.io_uring_sqe else struct { + // The scheduler sets `.len` on the poll_add SQE to ask for multishot. + len: u32 = 0, +}; + +pub const POLL_IN: i16 = if (have_io_uring) @intCast(std.os.linux.POLL.IN) else 1; +pub const POLL_ADD_MULTI: u32 = if (have_io_uring) std.os.linux.IORING_POLL_ADD_MULTI else 0; + +// A wake channel the ring can block on. On Linux this is the eventfd the +// scheduler already used; elsewhere it is a pipe, because there is no eventfd +// and a pipe is the portable one-writer/one-reader wakeup. +pub const WakeFd = struct { + read_fd: std.posix.fd_t = -1, + write_fd: std.posix.fd_t = -1, + + pub fn open() !WakeFd { + if (have_io_uring) { + const flags = std.os.linux.EFD.CLOEXEC | std.os.linux.EFD.NONBLOCK | std.os.linux.EFD.SEMAPHORE; + const fd = try compat.eventFd(0, flags); + return .{ .read_fd = fd, .write_fd = fd }; + } + var fds: [2]i32 = .{ -1, -1 }; + const libc = struct { + extern "c" fn pipe(f: *[2]i32) i32; + extern "c" fn fcntl(fd: i32, cmd: i32, arg: i32) i32; + }; + if (libc.pipe(&fds) != 0) return error.Unexpected; + // Non-blocking both ends: a full pipe must never stall a producer, and + // a spurious wake must never leave the scheduler blocked in read(). + const F_SETFL = 4; + const O_NONBLOCK = 0x0004; // Darwin/BSD value + _ = libc.fcntl(fds[0], F_SETFL, O_NONBLOCK); + _ = libc.fcntl(fds[1], F_SETFL, O_NONBLOCK); + return .{ .read_fd = fds[0], .write_fd = fds[1] }; + } + + pub fn close(self: *WakeFd) void { + compat.closeFd(self.read_fd); + if (self.write_fd != self.read_fd) compat.closeFd(self.write_fd); + } +}; + +// Poll-based stand-in for IoUring. Blocking is the only behaviour the +// scheduler's idle path actually needs from the ring on a compute-only run. +pub const PollRing = struct { + wake_fd: std.posix.fd_t = -1, + pending_timeout_ns: u64 = 0, + scratch: Sqe = .{}, + + pub fn init(entries: u16, flags: u32) !PollRing { + _ = entries; + _ = flags; + return .{}; + } + + pub fn deinit(self: *PollRing) void { + self.* = .{}; + } + + // The scheduler registers its wake fd once, with a multishot poll. + pub fn poll_add(self: *PollRing, user_data: u64, fd: std.posix.fd_t, mask: u32) !*Sqe { + _ = user_data; + _ = mask; + self.wake_fd = fd; + self.scratch = .{}; + return &self.scratch; + } + + pub fn timeout(self: *PollRing, user_data: u64, ts: *const KernelTimespec, count: u32, flags: u32) !*Sqe { + _ = user_data; + _ = count; + _ = flags; + const secs: i128 = @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); + self.pending_timeout_ns = if (secs <= 0) 0 else @intCast(secs); + self.scratch = .{}; + return &self.scratch; + } + + pub fn submit(self: *PollRing) !u32 { + _ = self; + return 0; + } + + // Returns 0 completions always: this backend never produces I/O results. + // With wait_nr > 0 it blocks first, so the scheduler idles instead of + // spinning, waking on the wake fd or when the queued timeout expires. + pub fn copy_cqes(self: *PollRing, cqes: []Cqe, wait_nr: u32) !u32 { + _ = cqes; + const timeout_ns = self.pending_timeout_ns; + self.pending_timeout_ns = 0; + if (wait_nr == 0) return 0; + + // A 50ms ceiling keeps a missed wake from hanging the process, and a + // 1ms floor keeps a sub-millisecond timeout from truncating to 0 and + // turning this park into a busy spin. + const ms: i32 = if (timeout_ns == 0) 50 else blk: { + const want = (timeout_ns + 999_999) / 1_000_000; + break :blk @intCast(@max(@as(u64, 1), @min(@as(u64, 50), want))); + }; + if (self.wake_fd < 0) { + // No wake fd registered yet: sleep the interval rather than spin. + const libc_sleep = struct { + extern "c" fn usleep(usec: u32) c_int; + }; + _ = libc_sleep.usleep(@as(u32, @intCast(ms)) * 1000); + return 0; + } + const libc = struct { + extern "c" fn poll(fds: *Pollfd, nfds: c_ulong, timeout: c_int) c_int; + }; + var pfd = Pollfd{ .fd = self.wake_fd, .events = POLL_IN, .revents = 0 }; + _ = libc.poll(&pfd, 1, ms); + return 0; + } + + pub fn read(self: *PollRing, user_data: u64, fd: std.posix.fd_t, buffer: anytype, offset: u64) !*Sqe { + _ = .{ self, user_data, fd, buffer, offset }; + return error.AsyncIoUnsupported; + } + pub fn write(self: *PollRing, user_data: u64, fd: std.posix.fd_t, buffer: []const u8, offset: u64) !*Sqe { + _ = .{ self, user_data, fd, buffer, offset }; + return error.AsyncIoUnsupported; + } + pub fn recv(self: *PollRing, user_data: u64, fd: std.posix.fd_t, buffer: anytype, flags: u32) !*Sqe { + _ = .{ self, user_data, fd, buffer, flags }; + return error.AsyncIoUnsupported; + } + pub fn send(self: *PollRing, user_data: u64, fd: std.posix.fd_t, buffer: []const u8, flags: u32) !*Sqe { + _ = .{ self, user_data, fd, buffer, flags }; + return error.AsyncIoUnsupported; + } + pub fn accept(self: *PollRing, user_data: u64, fd: std.posix.fd_t, addr: ?*anyopaque, addrlen: ?*anyopaque, flags: u32) !*Sqe { + _ = .{ self, user_data, fd, addr, addrlen, flags }; + return error.AsyncIoUnsupported; + } + pub fn connect(self: *PollRing, user_data: u64, fd: std.posix.fd_t, addr: anytype, addr_len: anytype) !*Sqe { + _ = .{ self, user_data, fd, addr, addr_len }; + return error.AsyncIoUnsupported; + } +}; + +const Pollfd = extern struct { + fd: i32, + events: i16, + revents: i16, +}; + +pub const DefaultRing = if (have_io_uring) std.os.linux.IoUring else PollRing; diff --git a/zig/runtime/scheduler.zig b/zig/runtime/scheduler.zig index aa40d253b..632ac04d9 100644 --- a/zig/runtime/scheduler.zig +++ b/zig/runtime/scheduler.zig @@ -99,8 +99,11 @@ pub fn ensureSignalAltStack() void { } const linux = std.os.linux; +// Platform selection for the completion ring. On Linux every alias in here IS +// the std.os.linux type, picked at comptime, so Linux codegen is unchanged. +const iob = @import("io-backend.zig"); const posix = std.posix; -const IoUring = linux.IoUring; +const IoUring = iob.DefaultRing; // Comptime io_uring type selection: SimRing in Loom mode, real IoUring otherwise. // When the root module exports SimRing (vopr-loom.zig), all io_uring submissions @@ -155,7 +158,10 @@ pub const SmartEventFd = struct { const WakeParked: u32 = 1; const WakeNotified: u32 = 2; - fd: i32, + // One fd on Linux (the eventfd is both ends); two on platforms without + // eventfd, where the wake channel is a pipe. + read_fd: i32, + write_fd: i32, // Parker state for cross-scheduler wake coalescing: // Empty -- scheduler is awake or no wake token is pending @@ -171,13 +177,13 @@ pub const SmartEventFd = struct { pub fn init() !SmartEventFd { // EFD_SEMAPHORE: Reads decrement counter by 1. // We use this so we can consume exactly one wake-up if needed. - const flags = std.os.linux.EFD.CLOEXEC | std.os.linux.EFD.NONBLOCK | std.os.linux.EFD.SEMAPHORE; - const fd = try compat.eventFd(0, flags); - return SmartEventFd{ .fd = fd }; + const wake = try iob.WakeFd.open(); + return SmartEventFd{ .read_fd = wake.read_fd, .write_fd = wake.write_fd }; } pub fn deinit(self: *SmartEventFd) void { - compat.closeFd(self.fd); + var wake = iob.WakeFd{ .read_fd = self.read_fd, .write_fd = self.write_fd }; + wake.close(); } /// Record a wake token. Returns true only when the target scheduler @@ -198,7 +204,7 @@ pub const SmartEventFd = struct { fn writeWake(self: *SmartEventFd) void { const val: u64 = 1; const bytes = std.mem.asBytes(&val); - _ = std.c.write(self.fd, bytes.ptr, bytes.len); + _ = std.c.write(self.write_fd, bytes.ptr, bytes.len); } /// Cold-path wake used by shutdown/watchdog code. This deliberately @@ -214,7 +220,7 @@ pub const SmartEventFd = struct { var val: u64 = 0; const buf = std.mem.asBytes(&val); // Drain the eventfd buffer - _ = std.posix.read(self.fd, buf) catch {}; + _ = std.posix.read(self.read_fd, buf) catch {}; } /// Prepare to block in io_uring. Returns false when a producer already @@ -339,7 +345,7 @@ pub const Scheduler = struct { // I/O, and eventfd wakeups. In Loom mode, this is SimRing. ring: RingType, ring_dirty: bool = false, - uring_cqes: [128]linux.io_uring_cqe = undefined, + uring_cqes: [128]iob.Cqe = undefined, // Dedicated stack for non-yielding io_uring calls made from run(). // This keeps helper frames off the scheduler's suspended switch slot. io_helper_stack: []u8, @@ -427,9 +433,9 @@ pub const Scheduler = struct { // Multishot means each eventfd write produces a new CQE without // re-submitting. user_data = EVENTFD_SENTINEL (0). if (RingType != @import("vopr-ring.zig").SimRing) { - const sqe = try ring.poll_add(EVENTFD_SENTINEL, efd.fd, linux.POLL.IN); + const sqe = try ring.poll_add(EVENTFD_SENTINEL, efd.read_fd, @intCast(iob.POLL_IN)); // Set POLL_ADD_MULTI so this poll persists across multiple fires. - sqe.len = linux.IORING_POLL_ADD_MULTI; + sqe.len = iob.POLL_ADD_MULTI; _ = try ring.submit(); } @@ -1383,7 +1389,7 @@ pub const Scheduler = struct { } if (timeout_ns > 0) { - const ts = linux.kernel_timespec{ + const ts = iob.KernelTimespec{ .sec = @intCast(timeout_ns / 1_000_000_000), .nsec = @intCast(timeout_ns % 1_000_000_000), }; @@ -2167,10 +2173,10 @@ pub const Scheduler = struct { return earliest; } - fn queueTimeoutOnIoStack(self: *Scheduler, ts: *const linux.kernel_timespec) void { + fn queueTimeoutOnIoStack(self: *Scheduler, ts: *const iob.KernelTimespec) void { const Ctx = struct { self: *Scheduler, - ts: *const linux.kernel_timespec, + ts: *const iob.KernelTimespec, fn run(raw: ?*anyopaque) callconv(.c) void { const ctx: *@This() = @ptrCast(@alignCast(raw.?)); _ = ctx.self.ring.timeout(TIMEOUT_SENTINEL, ctx.ts, 0, 0) catch {}; @@ -2215,7 +2221,7 @@ pub const Scheduler = struct { /// - File I/O completions (READ/WRITE) -> write result to IoWaiter, wake task /// - Eventfd wakeup (sentinel 0) -> consume eventfd /// - Timeout (sentinel 1) -> ignore - pub fn processCqes(self: *Scheduler, cqes: []const linux.io_uring_cqe) void { + pub fn processCqes(self: *Scheduler, cqes: []const iob.Cqe) void { for (cqes) |cqe| { const ud = cqe.user_data; if (ud == EVENTFD_SENTINEL) { diff --git a/zig/runtime/switch.S b/zig/runtime/switch.S index 8932cea49..49ffea9a0 100644 --- a/zig/runtime/switch.S +++ b/zig/runtime/switch.S @@ -253,20 +253,27 @@ switch_context_asm: .align 2 SYM(switch_context_asm): - // x0 = from, x1 = to + // x0 = from, x1 = to. Offsets follow fiber-core.zig's aarch64 Context + // EXACTLY: sp 0x00, then x19..x28 packed from 0x08, fp 0x58, lr 0x60. + // + // x29/x30 must be saved and restored. On aarch64 `ret` branches to x30, + // where x86's `ret` pops the return address off the stack -- so a switch + // that leaves x30 alone returns to whatever the previous frame left there. mov x9, sp str x9, [x0, #0x00] - stp x19, x20, [x0, #0x10] - stp x21, x22, [x0, #0x20] - stp x23, x24, [x0, #0x30] - stp x25, x26, [x0, #0x40] - stp x27, x28, [x0, #0x50] - - ldp x27, x28, [x1, #0x50] - ldp x25, x26, [x1, #0x40] - ldp x23, x24, [x1, #0x30] - ldp x21, x22, [x1, #0x20] - ldp x19, x20, [x1, #0x10] + stp x19, x20, [x0, #0x08] + stp x21, x22, [x0, #0x18] + stp x23, x24, [x0, #0x28] + stp x25, x26, [x0, #0x38] + stp x27, x28, [x0, #0x48] + stp x29, x30, [x0, #0x58] + + ldp x29, x30, [x1, #0x58] + ldp x27, x28, [x1, #0x48] + ldp x25, x26, [x1, #0x38] + ldp x23, x24, [x1, #0x28] + ldp x21, x22, [x1, #0x18] + ldp x19, x20, [x1, #0x08] ldr x9, [x1, #0x00] mov sp, x9 ret From 0f63c0b57197ec20f970fa66af138226316c6486 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Sun, 20 Sep 2026 21:30:42 -0500 Subject: [PATCH 04/17] Cover the new Mac ARM code, and fix the two bugs that surfaced Tests for everything this branch added that can hold logic: io-backend.zig (16 tests) and the two guard scripts (32 tests, 100% line coverage measured by tracing, all 15 conditionals driven both ways). Writing them found two real bugs in tools/zig-guard, both in the PATH fallback that runs whenever CLEAR_REAL_ZIG is not set: - `command -v -a` is a bashism. POSIX sh rejects it outright ("command: -a: invalid option"), so the fallback found nothing and the guard exited 127 every time it was invoked outside ./clear -- exactly the way the handoff doc tells people to use it. PATH is now walked by hand. - The candidate test was `-x` alone, but a DIRECTORY carries the execute bit, and an empty PATH element means the current directory. Run from the repo root, whose `zig/` is a directory, the guard selected that directory as the compiler. It now requires `-f` as well. The io-backend tests pin the behaviour the scheduler depends on: the comptime platform selection, the substituted Cqe/Sqe/KernelTimespec fields, the wake channel (both the two-fd pipe shape and the one-fd eventfd shape), the sec/nsec conversion and its non-positive clamp, the wait_nr == 0 fast path, parking on a readable fd, the 50ms ceiling, the 1ms floor that keeps a sub-millisecond timeout from busy-spinning, the no-wake-fd sleep, and every async submission reporting AsyncIoUnsupported. Descriptor exhaustion covers the pipe() failure path. The guard suite drives its own awkward branches rather than declaring them unreachable: `sysctl` is shadowed to fake a 2 GB host so the 4096 MB floor fires, and `ps` is shadowed to fail so the degrade-to-zero path runs. SIGTERM is delivered to prove the trap reaps the tree instead of orphaning a multi-gigabyte zig. compiler/spec/mem_guard_spec.rb runs the shell suite from the Ruby suite and asserts the 100% figure, so a regression fails CI instead of waiting to be noticed the next time zig tries to take a machine down. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KSuF9ba5qccHAU1gZc9oQ2 --- compiler/spec/mem_guard_spec.rb | 25 ++++ tools/test/mem_guard_test.sh | 234 ++++++++++++++++++++++++++++++ tools/zig-guard | 19 ++- zig/build.zig | 1 + zig/io-backend-test.zig | 245 ++++++++++++++++++++++++++++++++ 5 files changed, 521 insertions(+), 3 deletions(-) create mode 100644 compiler/spec/mem_guard_spec.rb create mode 100755 tools/test/mem_guard_test.sh create mode 100644 zig/io-backend-test.zig diff --git a/compiler/spec/mem_guard_spec.rb b/compiler/spec/mem_guard_spec.rb new file mode 100644 index 000000000..cb7394e9e --- /dev/null +++ b/compiler/spec/mem_guard_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# tools/mem-guard and tools/zig-guard are shell, so their tests are shell too +# (tools/test/mem_guard_test.sh). This wrapper is what puts them in the Ruby +# suite, so a broken guard fails CI rather than waiting to be noticed the next +# time a zig run tries to take the machine down. +# +# The slow end-to-end case -- a real `clear build` proving the ZIG constant +# routes through the wrapper -- runs only with SLOW=1. +require 'open3' + +RSpec.describe 'tools/mem-guard and tools/zig-guard' do + it 'passes its behavioural suite with full line coverage' do + root = File.expand_path('../..', __dir__) + script = File.join(root, 'tools', 'test', 'mem_guard_test.sh') + out, status = Open3.capture2e({ 'COVERAGE' => '1' }, script, chdir: root) + + aggregate_failures do + expect(status).to be_success, "guard suite failed:\n#{out}" + expect(out).to match(/mem-guard\s+\d+\/\d+ lines = 100\.0%/), "mem-guard not fully covered:\n#{out}" + expect(out).to match(/zig-guard\s+\d+\/\d+ lines = 100\.0%/), "zig-guard not fully covered:\n#{out}" + expect(out).to include('0 failed') + end + end +end diff --git a/tools/test/mem_guard_test.sh b/tools/test/mem_guard_test.sh new file mode 100755 index 000000000..1ff4fd6e2 --- /dev/null +++ b/tools/test/mem_guard_test.sh @@ -0,0 +1,234 @@ +#!/bin/bash +# Behavioural tests for tools/mem-guard and tools/zig-guard. +# +# Run with COVERAGE=1 to trace executed lines (PS4 carries $LINENO) and print a +# per-script line-coverage report. +set -uo pipefail +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +GUARD="$ROOT/tools/mem-guard" +ZGUARD="$ROOT/tools/zig-guard" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +pass=0; fail=0 + +ok() { if [ "$1" = "$2" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $3 (want '$2', got '$1')"; fi; } +contains() { case "$1" in *"$2"*) pass=$((pass+1));; *) fail=$((fail+1)); echo "FAIL: $3";; esac; } + +run() { # run