Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
84 changes: 55 additions & 29 deletions src/fiber.zig
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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();
Expand All @@ -672,3 +662,39 @@ 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);
}

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,
}
}
2 changes: 2 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ 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());
_ = fiber;
_ = @import("stack.zig");
}
105 changes: 105 additions & 0 deletions src/stack.zig
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 24 additions & 0 deletions test/overflow_probe.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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);
std.mem.doNotOptimizeAway(&buf); // keep the frame live past the call → not a tail call
}

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
}
Loading