Skip to content

refactor(js): unify env lifecycle refcounting#53

Open
nazarhussain wants to merge 2 commits into
mainfrom
nh/lifecycle-cleanup
Open

refactor(js): unify env lifecycle refcounting#53
nazarhussain wants to merge 2 commits into
mainfrom
nh/lifecycle-cleanup

Conversation

@nazarhussain

@nazarhussain nazarhussain commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Motivation

export_module.zig (bare atomic) and js/io.zig (mutex-guarded refcount) duplicate the same retain/release lifecycle. That split is the root cause of both open fixes: the cross-env init race (#31) and the missed cleanup on registration failure (#52).

Description

  • Add lifecycle.SharedResource(T, hooks): a mutex-guarded refcount that runs fallible init under the lock before incrementing, and cleanup after decrementing.
  • Port js/io.zig and the module lifecycle in export_module.zig onto it. No public API changes.

Both fixes hold by construction:

Supersedes #31 and #52.

Testing

New tests in lifecycle.zig cover hook ordering, init-failure rollback, and instance visibility. zig build test: 54/54 pass.

🤖 Generated with Claude Code

Module lifecycle used a bare atomic while js.io() had a mutex-guarded
refcount; the split allowed an init/cleanup race across concurrent
N-API envs and skipped cleanup when export registration failed after a
successful init. SharedResource serializes hooks under a lock and only
increments after init succeeds, so both hold by construction.

Refs #31, #52

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nazarhussain

Copy link
Copy Markdown
Contributor Author

@GrapeBaBa The changes in #31 and #52 were using the pattern and logic we already have in io.zig so I extracted it out to a common file lifecycle.zig and used across both io.zig and export_module.zig.

See if that seems good to you.

@nazarhussain
nazarhussain requested a review from GrapeBaBa July 17, 2026 07:02
@nazarhussain nazarhussain self-assigned this Jul 17, 2026

@spiral-ladder spiral-ladder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a few questions

Comment thread src/js/lifecycle.zig
/// holder).
///
/// Each instantiation owns its own refcount, mutex, and `T` instance.
pub fn SharedResource(comptime T: type, comptime hooks: anytype) type {

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

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.

Comment thread src/js/lifecycle.zig
}

/// Returns the shared instance, or null when no holder is active.
pub fn get() ?*T {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes correct, added to docs.

Comment thread src/js/lifecycle.zig
/// holder).
///
/// Each instantiation owns its own refcount, mutex, and `T` instance.
pub fn SharedResource(comptime T: type, comptime hooks: anytype) type {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 SharedResource, splitting a single state machine between the generic helper and its callers. That could make lifecycle correctness harder to reason about.

If we keep this abstraction, I think get() should make its lifetime requirements explicit. It returns *T after releasing the mutex, so SharedResource itself does not guarantee that the instance remains valid during use. Consider returning a lease that holds a retain until released. At minimum, document that the caller must already hold a retain for the entire lifetime of the returned pointer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 get() agreed the lifetime requirement should be explicit — I've documented that the returned pointer is only valid while the caller holds a retain.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@spiral-ladder spiral-ladder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this generally lgtm, but will wait on @GrapeBaBa for second opinion

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants