-
Notifications
You must be signed in to change notification settings - Fork 3
refactor(js): unify env lifecycle refcounting #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| const std = @import("std"); | ||
|
|
||
| /// Refcounted shared state with lifecycle hooks serialized under a mutex. | ||
| /// | ||
| /// Optional `hooks`: | ||
| /// | ||
| /// - `.init = fn (instance: *T, prev_refcount: u32) !void`: called under the | ||
| /// lock before the refcount is incremented (`prev_refcount` is 0 for the | ||
| /// first holder). On error the refcount is left untouched. | ||
| /// - `.cleanup = fn (instance: *T, new_refcount: u32) void`: called under the | ||
| /// lock after the refcount is decremented (`new_refcount` is 0 for the last | ||
| /// holder). | ||
| /// | ||
| /// Each instantiation owns its own refcount, mutex, and `T` instance. | ||
| pub fn SharedResource(comptime T: type, comptime hooks: anytype) type { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m not sure this abstraction is necessary. Different resource types may not share the same lifecycle state machine. If one resource’s lifecycle evolves independently, some transitions or invariants may have to be handled outside If we keep this abstraction, I think
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The abstraction is deliberately scoped to the one state machine we already have twice: env-scoped refcounted init/teardown. Having it in two copies is what produced #31 and #52. It's also internal — not exported from js.zig — so if a future resource's lifecycle doesn't fit, it just doesn't use this helper. On
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i also like that this makes the lifecycle concern in zapi rather than consumer |
||
| const has_init = @hasField(@TypeOf(hooks), "init"); | ||
| const has_cleanup = @hasField(@TypeOf(hooks), "cleanup"); | ||
|
|
||
| return struct { | ||
| var mutex: std.Io.Mutex = .init; | ||
| var refcount: u32 = 0; | ||
| var instance: T = undefined; | ||
|
|
||
| /// Returns the refcount before this retain (0 for the first holder). | ||
| pub fn retain() !u32 { | ||
| std.Io.Threaded.mutexLock(&mutex); | ||
| defer std.Io.Threaded.mutexUnlock(&mutex); | ||
|
|
||
| const prev = refcount; | ||
| if (has_init) try hooks.init(&instance, prev); | ||
| refcount += 1; | ||
| return prev; | ||
| } | ||
|
|
||
| pub fn release() void { | ||
| std.Io.Threaded.mutexLock(&mutex); | ||
| defer std.Io.Threaded.mutexUnlock(&mutex); | ||
|
|
||
| std.debug.assert(refcount > 0); | ||
| refcount -= 1; | ||
| if (has_cleanup) hooks.cleanup(&instance, refcount); | ||
| } | ||
|
|
||
| /// Returns the shared instance, or null when no holder is active. | ||
| /// | ||
| /// The pointer is only valid while the caller holds a retain; without | ||
| /// one, a concurrent last `release()` may tear the instance down. | ||
| pub fn get() ?*T { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. im assuming the resource has to be retained for this to be threadsafe?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes correct, added to docs. |
||
| std.Io.Threaded.mutexLock(&mutex); | ||
| defer std.Io.Threaded.mutexUnlock(&mutex); | ||
|
|
||
| if (refcount == 0) return null; | ||
| return &instance; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| test "SharedResource runs init before increment and cleanup after decrement" { | ||
| const Hooks = struct { | ||
| var inits: [2]u32 = undefined; | ||
| var init_count: usize = 0; | ||
| var cleanups: [2]u32 = undefined; | ||
| var cleanup_count: usize = 0; | ||
|
|
||
| fn init(_: *void, prev_refcount: u32) !void { | ||
| inits[init_count] = prev_refcount; | ||
| init_count += 1; | ||
| } | ||
| fn cleanup(_: *void, new_refcount: u32) void { | ||
| cleanups[cleanup_count] = new_refcount; | ||
| cleanup_count += 1; | ||
| } | ||
| }; | ||
| const S = SharedResource(void, .{ .init = Hooks.init, .cleanup = Hooks.cleanup }); | ||
|
|
||
| try std.testing.expectEqual(@as(u32, 0), try S.retain()); | ||
| try std.testing.expectEqual(@as(u32, 1), try S.retain()); | ||
| S.release(); | ||
| S.release(); | ||
|
|
||
| try std.testing.expectEqualSlices(u32, &.{ 0, 1 }, Hooks.inits[0..2]); | ||
| try std.testing.expectEqualSlices(u32, &.{ 1, 0 }, Hooks.cleanups[0..2]); | ||
| } | ||
|
|
||
| test "SharedResource leaves refcount untouched and skips cleanup when init fails" { | ||
| const Hooks = struct { | ||
| var fail: bool = true; | ||
| var cleanup_count: u32 = 0; | ||
|
|
||
| fn init(_: *void, _: u32) !void { | ||
| if (fail) return error.InitFailed; | ||
| } | ||
| fn cleanup(_: *void, _: u32) void { | ||
| cleanup_count += 1; | ||
| } | ||
| }; | ||
| const S = SharedResource(void, .{ .init = Hooks.init, .cleanup = Hooks.cleanup }); | ||
|
|
||
| try std.testing.expectError(error.InitFailed, S.retain()); | ||
| try std.testing.expect(S.get() == null); | ||
| try std.testing.expectEqual(@as(u32, 0), Hooks.cleanup_count); | ||
|
|
||
| Hooks.fail = false; | ||
| try std.testing.expectEqual(@as(u32, 0), try S.retain()); | ||
| S.release(); | ||
| try std.testing.expectEqual(@as(u32, 1), Hooks.cleanup_count); | ||
| } | ||
|
|
||
| test "SharedResource exposes the instance only while retained" { | ||
| const Hooks = struct { | ||
| fn init(instance: *u32, prev_refcount: u32) !void { | ||
| if (prev_refcount == 0) instance.* = 42; | ||
| } | ||
| }; | ||
| const S = SharedResource(u32, .{ .init = Hooks.init }); | ||
|
|
||
| try std.testing.expect(S.get() == null); | ||
| _ = try S.retain(); | ||
| try std.testing.expectEqual(@as(u32, 42), S.get().?.*); | ||
| S.release(); | ||
| try std.testing.expect(S.get() == null); | ||
| } | ||
|
|
||
| test "SharedResource serializes hooks across threads" { | ||
| const thread_count = 4; | ||
| const iterations = 100; | ||
|
|
||
| const Hooks = struct { | ||
| var busy: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); | ||
| var overlapped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false); | ||
| // Non-atomic on purpose: serialized hooks must not race on these. | ||
| var init_count: u32 = 0; | ||
| var cleanup_count: u32 = 0; | ||
|
|
||
| fn enter() void { | ||
| if (busy.swap(true, .acquire)) overlapped.store(true, .seq_cst); | ||
| } | ||
| fn exit() void { | ||
| busy.store(false, .release); | ||
| } | ||
| fn init(_: *void, _: u32) !void { | ||
| enter(); | ||
| defer exit(); | ||
| init_count += 1; | ||
| } | ||
| fn cleanup(_: *void, _: u32) void { | ||
| enter(); | ||
| defer exit(); | ||
| cleanup_count += 1; | ||
| } | ||
| }; | ||
| const S = SharedResource(void, .{ .init = Hooks.init, .cleanup = Hooks.cleanup }); | ||
|
|
||
| const worker = struct { | ||
| fn run() void { | ||
| for (0..iterations) |_| { | ||
| _ = S.retain() catch unreachable; | ||
| std.debug.assert(S.get() != null); | ||
| S.release(); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| var threads: [thread_count]std.Thread = undefined; | ||
| for (&threads) |*t| t.* = try std.Thread.spawn(.{}, worker.run, .{}); | ||
| for (threads) |t| t.join(); | ||
|
|
||
| try std.testing.expect(!Hooks.overlapped.load(.seq_cst)); | ||
| try std.testing.expectEqual(@as(u32, thread_count * iterations), Hooks.init_count); | ||
| try std.testing.expectEqual(@as(u32, thread_count * iterations), Hooks.cleanup_count); | ||
| try std.testing.expect(S.get() == null); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we add a test that exercises the mutex?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes added the tests for it.