From 35d089f48d4b6bca0c032f4597027d6ba73d7859 Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 23:07:47 +0900 Subject: [PATCH 1/5] feat: add a guard-paged stack allocator New src/stack.zig maps a fiber stack with one no-access guard page immediately below the usable region, so a stack overflow faults instead of silently corrupting memory. Linux uses mmap + a raw mprotect syscall; Windows reserves the whole region and commits only the usable part, leaving the guard page reserved-but-uncommitted (any access faults). Supported on Linux and Windows; compileError elsewhere. --- src/root.zig | 1 + src/stack.zig | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/stack.zig diff --git a/src/root.zig b/src/root.zig index 01f8df3..c9e698f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -9,4 +9,5 @@ pub const yield = fiber.Fiber.yield; test { @import("std").testing.refAllDecls(@This()); _ = fiber; + _ = @import("stack.zig"); } diff --git a/src/stack.zig b/src/stack.zig new file mode 100644 index 0000000..f15510b --- /dev/null +++ b/src/stack.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +comptime { + if (builtin.os.tag != .linux and builtin.os.tag != .windows) { + @compileError("fiber guard-paged stacks require Linux or Windows"); + } +} + +/// Allocate a guard-protected stack: a usable region of at least `size` bytes, +/// rounded up to a page multiple, with one no-access guard page immediately +/// below it. A stack overflow faults on the guard page instead of corrupting +/// memory. Returns the usable region (the guard page is not part of the slice). +pub fn alloc(size: usize) error{OutOfMemory}![]u8 { + const ps = std.heap.pageSize(); + const usable_len = std.mem.alignForward(usize, size, ps); + const total = usable_len + ps; // usable + one guard page + return switch (builtin.os.tag) { + .linux => allocLinux(total, usable_len, ps), + .windows => allocWindows(total, usable_len, ps), + else => comptime unreachable, + }; +} + +/// Free a stack returned by `alloc`. +pub fn free(usable: []u8) void { + const ps = std.heap.pageSize(); + switch (builtin.os.tag) { + .linux => freeLinux(usable, ps), + .windows => freeWindows(usable, ps), + else => comptime unreachable, + } +} + +fn allocLinux(total: usize, usable_len: usize, ps: usize) error{OutOfMemory}![]u8 { + const mapping = std.posix.mmap( + null, + total, + .{ .READ = true, .WRITE = true }, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ) catch return error.OutOfMemory; + errdefer std.posix.munmap(mapping); + // Make the low page a no-access guard. std.posix.mprotect is absent in + // Zig 0.16, so use the raw Linux syscall; PROT all-false = PROT_NONE. + const rc = std.os.linux.mprotect(mapping.ptr, ps, .{}); + if (std.os.linux.errno(rc) != .SUCCESS) return error.OutOfMemory; + return mapping[ps .. ps + usable_len]; +} + +fn freeLinux(usable: []u8, ps: usize) void { + const base: [*]align(std.heap.page_size_min) u8 = @alignCast(usable.ptr - ps); + std.posix.munmap(base[0 .. usable.len + ps]); +} + +fn allocWindows(total: usize, usable_len: usize, ps: usize) error{OutOfMemory}![]u8 { + const w = std.os.windows; + const proc = w.GetCurrentProcess(); + // Reserve the whole region; the guard page stays reserved-but-uncommitted, + // so any access to it raises an access violation. + var base: ?*anyopaque = null; + var reserve_size: w.SIZE_T = total; + if (w.ntdll.NtAllocateVirtualMemory(proc, @ptrCast(&base), 0, &reserve_size, .{ .RESERVE = true }, .{ .READWRITE = true }) != .SUCCESS) { + return error.OutOfMemory; + } + const region_base = @intFromPtr(base); + errdefer { + var b: ?*anyopaque = @ptrFromInt(region_base); + var s: w.SIZE_T = 0; + _ = w.ntdll.NtFreeVirtualMemory(proc, @ptrCast(&b), &s, .{ .RELEASE = true }); + } + // Commit only the usable region (above the guard page). + var commit: ?*anyopaque = @ptrFromInt(region_base + ps); + var commit_size: w.SIZE_T = usable_len; + if (w.ntdll.NtAllocateVirtualMemory(proc, @ptrCast(&commit), 0, &commit_size, .{ .COMMIT = true }, .{ .READWRITE = true }) != .SUCCESS) { + return error.OutOfMemory; + } + const usable_ptr: [*]u8 = @ptrFromInt(region_base + ps); + return usable_ptr[0..usable_len]; +} + +fn freeWindows(usable: []u8, ps: usize) void { + const w = std.os.windows; + var base: ?*anyopaque = @ptrFromInt(@intFromPtr(usable.ptr) - ps); + var size: w.SIZE_T = 0; // 0 + RELEASE frees the whole reservation from base + _ = w.ntdll.NtFreeVirtualMemory(w.GetCurrentProcess(), @ptrCast(&base), &size, .{ .RELEASE = true }); +} + +test "alloc returns a page-aligned, page-rounded, writable usable region" { + const ps = std.heap.pageSize(); + const s = try alloc(ps); + defer free(s); + try std.testing.expect(@intFromPtr(s.ptr) % ps == 0); + try std.testing.expect(s.len % ps == 0); + try std.testing.expect(s.len >= ps); + @memset(s, 0); // the usable region must be writable (not the guard page) +} + +test "alloc rounds a non-page-multiple size up to a page" { + const ps = std.heap.pageSize(); + const s = try alloc(ps + 1); + defer free(s); + try std.testing.expectEqual(std.mem.alignForward(usize, ps + 1, ps), s.len); +} From 58688df01fa79152c15a26903b62f83c51b2f89b Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 23:21:36 +0900 Subject: [PATCH 2/5] feat: back fiber stacks with guard-paged OS memory create now allocates the stack via src/stack.zig (guard-paged) instead of the passed allocator, which now backs only the Fiber struct; destroy frees it via stack.zig. Revise the OOM test (the stack no longer uses the allocator, so only the struct allocation can fail) and re-export min_stack_size from root. --- src/fiber.zig | 60 ++++++++++++++++++++++++++------------------------- src/root.zig | 1 + 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/fiber.zig b/src/fiber.zig index f452e1f..1a31867 100644 --- a/src/fiber.zig +++ b/src/fiber.zig @@ -1,6 +1,7 @@ const std = @import("std"); const context = @import("arch/context.zig"); const Context = context.Context; +const stack_mod = @import("stack.zig"); pub const State = enum { ready, running, suspended, done }; @@ -37,6 +38,8 @@ pub const Fiber = struct { /// Allocate a fiber and its stack, ready to run `entry`; does not start it. /// Returns `error.StackTooSmall` (before allocating) if /// `options.stack_size < min_stack_size`. + /// The stack is an OS mapping with a no-access guard page below it; the + /// passed allocator backs only the Fiber struct. pub fn create( allocator: std.mem.Allocator, entry: *const fn (*Fiber) void, @@ -47,8 +50,8 @@ pub const Fiber = struct { const self = try allocator.create(Fiber); errdefer allocator.destroy(self); - const stack = try allocator.alloc(u8, options.stack_size); - errdefer allocator.free(stack); + const stack = try stack_mod.alloc(options.stack_size); // guard-paged; not from `allocator` + errdefer stack_mod.free(stack); self.* = .{ .stack = stack, @@ -78,7 +81,7 @@ pub const Fiber = struct { pub fn destroy(self: *Fiber) void { std.debug.assert(self.state != .running); // freeing a running stack is UB const allocator = self.allocator; - allocator.free(self.stack); + stack_mod.free(self.stack); allocator.destroy(self); } @@ -495,31 +498,17 @@ test "create reports OutOfMemory and leaks nothing on allocation failure" { fn work(_: *Fiber) void {} }; - // Fail allocation index 0 (the Fiber struct): create returns the error and - // nothing is allocated. - { - var failing = std.testing.FailingAllocator.init( - std.testing.allocator, - .{ .fail_index = 0 }, - ); - try std.testing.expectError( - error.OutOfMemory, - Fiber.create(failing.allocator(), &S.work, .{}), - ); - } - - // Fail allocation index 1 (the stack): the Fiber struct allocation must be - // rolled back by errdefer. std.testing.allocator flags any leak. - { - var failing = std.testing.FailingAllocator.init( - std.testing.allocator, - .{ .fail_index = 1 }, - ); - try std.testing.expectError( - error.OutOfMemory, - Fiber.create(failing.allocator(), &S.work, .{}), - ); - } + // The passed allocator now backs only the Fiber struct (the stack is an OS + // mapping). Failing that single allocation must surface OutOfMemory with no + // leak. (An mmap/NtAllocate failure is impractical to inject and is not tested.) + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + try std.testing.expectError( + error.OutOfMemory, + Fiber.create(failing.allocator(), &S.work, .{}), + ); } test "fiber data payload is passed through and readable by the entry" { @@ -657,7 +646,8 @@ test "create accepts the minimum stack size" { const S = struct { var ticks: u32 = 0; // Minimal body by design: at the 4096 floor only ~3.8 KiB remains after - // the setup frame, and there is no guard page yet (C3). + // the setup frame, and a 4096-byte stack overflowing its usable region + // now faults on the guard page (C3). fn work(_: *Fiber) void { ticks += 1; Fiber.yield(); @@ -672,3 +662,15 @@ test "create accepts the minimum stack size" { while (f.state != .done) f.resumeFiber(); try std.testing.expectEqual(@as(u32, 2), S.ticks); } + +test "a created fiber's stack is page-aligned and page-rounded" { + const ps = std.heap.pageSize(); + const S = struct { + fn work(_: *Fiber) void {} + }; + const f = try Fiber.create(std.testing.allocator, &S.work, .{ .stack_size = min_stack_size }); + defer f.destroy(); + try std.testing.expect(@intFromPtr(f.stack.ptr) % ps == 0); + try std.testing.expect(f.stack.len % ps == 0); + try std.testing.expect(f.stack.len >= min_stack_size); +} diff --git a/src/root.zig b/src/root.zig index c9e698f..84a181e 100644 --- a/src/root.zig +++ b/src/root.zig @@ -5,6 +5,7 @@ const fiber = @import("fiber.zig"); pub const Fiber = fiber.Fiber; pub const State = fiber.State; pub const yield = fiber.Fiber.yield; +pub const min_stack_size = fiber.min_stack_size; test { @import("std").testing.refAllDecls(@This()); From 7acc3f07aed494f54af5d86f1ceb68691fd134fe Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 23:40:48 +0900 Subject: [PATCH 3/5] test: prove a stack overflow faults on the guard page Add a standalone overflow-probe exe that overflows a guarded fiber, and a test that spawns it and asserts abnormal termination (a signal on POSIX, a non-zero exit on Windows). The probe is fail-closed: any non-fault outcome exits 0, which the test treats as failure, so a setup error cannot masquerade as a guard fault. build.zig installs the probe and passes its path via FIBER_OVERFLOW_PROBE. --- build.zig | 17 +++++++++++++++++ src/fiber.zig | 24 ++++++++++++++++++++++++ test/overflow_probe.zig | 23 +++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 test/overflow_probe.zig diff --git a/build.zig b/build.zig index 3d6a6d7..3a14473 100644 --- a/build.zig +++ b/build.zig @@ -33,9 +33,26 @@ pub fn build(b: *std.Build) void { example_step.dependOn(&run_example.step); } + // Overflow probe: a standalone exe the guard-fault test spawns. + const probe = b.addExecutable(.{ + .name = "overflow-probe", + .root_module = b.createModule(.{ + .root_source_file = b.path("test/overflow_probe.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "fiber", .module = fiber_mod }}, + }), + }); + const probe_install = b.addInstallArtifact(probe, .{}); + // Tests: `zig build test`. const tests = b.addTest(.{ .root_module = fiber_mod }); const run_tests = b.addRunArtifact(tests); + run_tests.step.dependOn(&probe_install.step); + run_tests.setEnvironmentVariable( + "FIBER_OVERFLOW_PROBE", + b.getInstallPath(.bin, b.fmt("overflow-probe{s}", .{target.result.exeFileExt()})), + ); const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_tests.step); } diff --git a/src/fiber.zig b/src/fiber.zig index 1a31867..24c6b13 100644 --- a/src/fiber.zig +++ b/src/fiber.zig @@ -674,3 +674,27 @@ test "a created fiber's stack is page-aligned and page-rounded" { try std.testing.expect(f.stack.len % ps == 0); try std.testing.expect(f.stack.len >= min_stack_size); } + +test "a stack overflow faults on the guard page" { + const allocator = std.testing.allocator; + + // The probe path is provided by build.zig when run via `zig build test`. + // This Zig's std.process has no getEnvVarOwned; std.testing.environ (set + // by the test runner from the process's real environment) is the current + // equivalent. + const probe = std.testing.environ.getAlloc(allocator, "FIBER_OVERFLOW_PROBE") catch return error.SkipZigTest; + defer allocator.free(probe); + + var child = try std.process.spawn(std.testing.io, .{ .argv = &.{probe} }); + const term = try child.wait(std.testing.io); + + switch (term) { + // POSIX: the guard fault kills the process with a signal (SIGSEGV + // expected). The probe is fail-closed, so a signal can only be the guard. + .signal => {}, + // Windows: an access violation terminates with a non-zero code. exit 0 + // means the probe survived (or hit a setup error) → the guard did not fire. + .exited => |code| try std.testing.expect(code != 0), + else => return error.TestUnexpectedResult, + } +} diff --git a/test/overflow_probe.zig b/test/overflow_probe.zig new file mode 100644 index 0000000..7475a96 --- /dev/null +++ b/test/overflow_probe.zig @@ -0,0 +1,23 @@ +const std = @import("std"); +const fiber = @import("fiber"); + +fn recurse(depth: usize) void { + var buf: [256]u8 = undefined; // a live per-frame buffer defeats tail-call elimination + buf[0] = @truncate(depth); + std.mem.doNotOptimizeAway(&buf); + recurse(depth + 1); +} + +fn overflow(_: *fiber.Fiber) void { + recurse(0); +} + +pub fn main(init: std.process.Init) !void { + // Fail closed: a genuine guard fault kills the process below, before any + // exit(). A setup error, or surviving the overflow, exits 0 — which the test + // reads as "the guard did NOT fire" and fails. + const f = fiber.Fiber.create(init.gpa, &overflow, .{ .stack_size = fiber.min_stack_size }) catch std.process.exit(0); + defer f.destroy(); + f.resumeFiber(); // overflows into the guard page → fault; never returns + std.process.exit(0); // reached only if the guard did NOT fire +} From f27b0cb26d1f8fd7ee79825e63e518163c4bd08d Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 23:55:08 +0900 Subject: [PATCH 4/5] docs: document stack guard pages and the OS-backed stack --- CHANGELOG.md | 5 +++++ README.md | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4624435..5dd5370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,11 +21,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `min_stack_size` and a `create` check returning `error.StackTooSmall` for a stack smaller than one page (a too-small stack previously corrupted the heap during setup), plus a `destroy` precondition that the fiber must not be running. +- A no-access guard page below every fiber stack: a stack overflow now faults + cleanly (SIGSEGV / access violation) instead of silently corrupting memory + (x86_64 Linux and Windows). ### Changed - `Fiber.create` now takes an `Options` argument; pass `.{}` for the previous defaults. +- `create` now allocates the stack from the OS (a guarded mapping), not the + passed allocator; the allocator backs only the `Fiber` struct. ### Fixed diff --git a/README.md b/README.md index 8cffa25..26f490d 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,12 @@ Each fiber allocates a stack — **64 KiB by default**, configurable via `Option own `*Fiber` so it can reach fiber-local data; returning from `entry` moves the fiber to `.done`, after which it must not be resumed again. +Each fiber's stack has a no-access **guard page** immediately below it, so a +stack overflow faults cleanly (SIGSEGV on Linux, an access violation on Windows) +instead of silently corrupting adjacent memory. The stack is an OS mapping +(`mmap`/`VirtualAlloc`); the allocator passed to `create` backs only the small +`Fiber` struct. Guard-paged stacks require x86_64 Linux or Windows. + Fibers are single-threaded: a fiber and the code that resumes it must run on the same OS thread. `current` is tracked per-thread, so nested resumes (a fiber resuming another fiber) restore the correct caller chain. @@ -125,6 +131,10 @@ The context switch is implemented in per-target assembly: Unsupported targets fail at compile time with a clear message rather than miscompiling. +Guard-paged stacks (and therefore `Fiber.create`) additionally require x86_64 +Linux or Windows — the BSD entries above describe the context switch only, not +stack allocation. + ## Testing ```sh From 8501f401dd1ff17ad7767332793531ac81e1a9c3 Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Fri, 24 Jul 2026 00:06:49 +0900 Subject: [PATCH 5/5] test: keep the overflow probe frame live across the recursive call Consume the per-frame buffer after the recursive call so the frame must outlive it, provably defeating tail-call optimization. Otherwise an optimized build could turn the overflow recursion into a non-growing loop and the guard page would never fault, silently invalidating the proof. --- test/overflow_probe.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/test/overflow_probe.zig b/test/overflow_probe.zig index 7475a96..aa9c5af 100644 --- a/test/overflow_probe.zig +++ b/test/overflow_probe.zig @@ -6,6 +6,7 @@ fn recurse(depth: usize) void { buf[0] = @truncate(depth); std.mem.doNotOptimizeAway(&buf); recurse(depth + 1); + std.mem.doNotOptimizeAway(&buf); // keep the frame live past the call → not a tail call } fn overflow(_: *fiber.Fiber) void {