From f0a9a6b8be96e7dc3a2c3d677083dbcbdc261ba7 Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 19:52:32 +0900 Subject: [PATCH 1/3] feat: reject a stack smaller than min_stack_size in create A stack_size below the initial setup frame made initStack write past the low end of the allocation and silently corrupt the heap. Add a public min_stack_size (one page) and return error.StackTooSmall before allocating anything when options.stack_size is below it. The error is additive to create's inferred error set; no existing caller passes a sub-page stack. --- src/fiber.zig | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/fiber.zig b/src/fiber.zig index d63830f..dcbc88e 100644 --- a/src/fiber.zig +++ b/src/fiber.zig @@ -7,6 +7,13 @@ pub const State = enum { ready, running, suspended, done }; /// Default fiber stack size (64 KiB), used when `Options.stack_size` is unset. pub const default_stack_size = 64 * 1024; +/// Smallest stack `create` accepts (one page). Comfortably above the initial +/// frame `initStack` writes (~280 bytes on Windows), it prevents a too-small +/// stack from corrupting the heap during setup, and catches unit-confusion +/// mistakes (bytes vs. KiB). It is a floor, not a recommendation: a stack this +/// small holds the setup frame and ~3.8 KiB of working space, little more. +pub const min_stack_size = 4096; + /// Options for `Fiber.create`. pub const Options = struct { /// Size in bytes of the stack allocated for the fiber. @@ -32,6 +39,8 @@ pub const Fiber = struct { entry: *const fn (*Fiber) void, options: Options, ) !*Fiber { + if (options.stack_size < min_stack_size) return error.StackTooSmall; + const self = try allocator.create(Fiber); errdefer allocator.destroy(self); @@ -603,3 +612,53 @@ test "reset re-arms a finished fiber for reuse without reallocating" { while (f.state != .done) f.resumeFiber(); try std.testing.expectEqual(@as(u32, 115), counter); } + +test "create rejects a stack size below the minimum before allocating" { + const S = struct { + fn work(_: *Fiber) void {} + }; + + // Below the floor -> error. + try std.testing.expectError( + error.StackTooSmall, + Fiber.create(std.testing.allocator, &S.work, .{ .stack_size = 0 }), + ); + try std.testing.expectError( + error.StackTooSmall, + Fiber.create(std.testing.allocator, &S.work, .{ .stack_size = min_stack_size - 1 }), + ); + + // Prove the check precedes any allocation: with a FailingAllocator whose + // first allocation fails, a too-small size must still yield StackTooSmall + // (the allocator is never reached) rather than OutOfMemory. + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + try std.testing.expectError( + error.StackTooSmall, + Fiber.create(failing.allocator(), &S.work, .{ .stack_size = 0 }), + ); +} + +test "create accepts the minimum stack size" { + const allocator = std.testing.allocator; + + 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). + fn work(_: *Fiber) void { + ticks += 1; + Fiber.yield(); + ticks += 1; + } + }; + S.ticks = 0; + + const f = try Fiber.create(allocator, &S.work, .{ .stack_size = min_stack_size }); + defer f.destroy(); + + while (f.state != .done) f.resumeFiber(); + try std.testing.expectEqual(@as(u32, 2), S.ticks); +} From cac27161a6925d84da5fdd0a40a19af1c6b3851d Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 20:00:49 +0900 Subject: [PATCH 2/3] feat: guard destroy against a running fiber and document preconditions Assert a fiber is not .running before destroy frees its stack (freeing the in-use stack is undefined). Document the state preconditions of create, destroy, resumeFiber, and yield so callers know the contract the debug asserts enforce. --- src/fiber.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/fiber.zig b/src/fiber.zig index dcbc88e..f452e1f 100644 --- a/src/fiber.zig +++ b/src/fiber.zig @@ -34,6 +34,9 @@ pub const Fiber = struct { allocator: std.mem.Allocator, data: ?*anyopaque = null, + /// 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`. pub fn create( allocator: std.mem.Allocator, entry: *const fn (*Fiber) void, @@ -70,7 +73,10 @@ pub const Fiber = struct { self.setupStack(); } + /// Free the fiber and its stack. Must not be called on a `.running` fiber — + /// that frees the stack in use. `.ready`, `.suspended`, and `.done` are fine. 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); allocator.destroy(self); @@ -80,6 +86,8 @@ pub const Fiber = struct { self.context.rsp = context.initStack(self.stack, &trampoline, &trampolineTrap); } + /// Switch into the fiber and run until it yields or finishes. The fiber must + /// be `.ready` or `.suspended` (never `.running` or `.done`). pub fn resumeFiber(self: *Fiber) void { std.debug.assert(self.state == .ready or self.state == .suspended); @@ -95,6 +103,8 @@ pub const Fiber = struct { current = prev; } + /// Suspend the running fiber and switch back to its caller. Must be called + /// from within a running fiber; panics otherwise. pub fn yield() void { const self = current orelse @panic("yield() called outside of a fiber"); self.state = .suspended; From 8b0d3cb064bf118229e0e8d1ff5581cde3c31d4e Mon Sep 17 00:00:00 2001 From: itsakeyfut Date: Thu, 23 Jul 2026 20:10:53 +0900 Subject: [PATCH 3/3] docs: document min_stack_size and the create/destroy preconditions --- CHANGELOG.md | 3 +++ README.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aba252..4624435 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Options` for `Fiber.create` (`stack_size`, `data`), a public `data` (`?*anyopaque`) field for per-fiber payloads, and `Fiber.reset` to re-arm a finished fiber for pool reuse without reallocating. +- `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. ### Changed diff --git a/README.md b/README.md index b662291..8cffa25 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ The `fiber` module exposes a single stackful fiber type. | Symbol | Signature | Description | | --- | --- | --- | | `Options` | `struct { stack_size: usize = 64*1024, data: ?*anyopaque = null }` | Options for `create` — stack size and an initial `data` payload. | -| `Fiber.create` | `(allocator, entry, options: Options) !*Fiber` | Allocate a fiber and its stack. `options` sets the stack size and initial `data`. Pass `.{}` for defaults. Does not start it. | +| `Fiber.create` | `(allocator, entry, options: Options) !*Fiber` | Allocate a fiber and its stack. `options` sets the stack size and initial `data`. Pass `.{}` for defaults. Returns `error.StackTooSmall` if `stack_size < min_stack_size`. Does not start it. | +| `min_stack_size` | `usize` (`4096`) | The smallest `stack_size` `create` accepts — one page. A floor, not a recommendation. | | `Fiber.reset` | `(*Fiber, entry, data: ?*anyopaque) void` | Re-arm a finished (or never-started) fiber with a new entry and data, reusing its stack. The pooling primitive. | | `data` | `?*anyopaque` field | Per-fiber user payload; set via `Options` or directly, read in the entry via `fiber.data`. | | `Fiber.destroy` | `(*Fiber) void` | Free the fiber and its stack. |